﻿# Webhooks

Webhooks let you receive an HTTP callback when a job reaches a terminal state (`completed`, `failed`, `cancelled`, or `timed_out`) instead of polling `GET /simc/jobs/{id}/status` in a loop.

There are two parts to using webhooks:

1. **Configure a webhook endpoint** on your client (one-time setup in the dashboard)
2. **Subscribe individual jobs** to webhook events at submission time

## Configure your endpoint

Before any webhook can fire, your client needs a destination URL and signing secret.

1. Go to [Clients & Keys](/clients) and select your client
2. Click the **Webhook** tab
3. Enter your HTTPS endpoint URL and click **Enable webhooks**
4. Copy the signing secret. It is only shown once.

You can rotate the secret or disable webhooks from the same page at any time.

> Your endpoint must be publicly reachable. Private IPs, `localhost`, and internal hostnames are blocked.

## Subscribe a job

Add a `webhook` object to your `POST /simc/jobs` request body to opt in for that job:

**Node.js**

```javascript
const response = await fetch('https://api.simmit.com/v1/simc/jobs', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${secretKey}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    build: { channel: 'latest' },
    profile: { text: profileText },
    webhook: {
      events: ['job.terminal']
    }
  })
})
```

**Python**

```python
response = requests.post(
    f"{BASE_URL}/simc/jobs",
    headers={
        "Authorization": f"Bearer {SECRET_KEY}",
        "Content-Type": "application/json",
    },
    json={
        "build": {"channel": "latest"},
        "profile": {"text": profile_text},
        "webhook": {
            "events": ["job.terminal"],
        },
    },
    timeout=30,
)
```

The `events` array accepts `job.terminal`. The webhook fires once the job reaches any terminal status. For failed jobs with retries, the webhook fires only after the final attempt.

If your client does not have a webhook URL and secret configured, the API will reject the submission with a `400` error (`webhook_not_configured`). Configure your endpoint in the dashboard first (see above).

## Payload format

When a subscribed job reaches a terminal state, Simmit sends an HTTP `POST` to your configured URL with a JSON body:

```json
{
  "kind": "job.terminal",
  "version": "v1",
  "timestamp": "2026-03-25T12:00:00.000Z",
  "payload": {
    "id": "519253542012420096",
    "status": "completed",
    "statusReason": null
  }
}
```

| Field                  | Description                                                          |
| ---------------------- | -------------------------------------------------------------------- |
| `kind`                 | Event type: `job.terminal`.                                          |
| `version`              | Schema version: `v1`.                                                |
| `timestamp`            | ISO 8601 timestamp of when the event was created.                    |
| `payload.id`           | The job ID.                                                          |
| `payload.status`       | Terminal status: `completed`, `failed`, `cancelled`, or `timed_out`. |
| `payload.statusReason` | Human-readable reason string, or `null`.                             |

## Request headers

Every webhook delivery includes these headers:

| Header                 | Description                                       |
| ---------------------- | ------------------------------------------------- |
| `X-Simmit-Signature`   | Signature for verifying authenticity (see below). |
| `X-Simmit-Event-Id`    | Unique ID for the event. Stable across retries.   |
| `X-Simmit-Delivery-Id` | Unique ID for this specific delivery attempt.     |
| `Content-Type`         | Always `application/json`.                        |

## Verifying signatures

Every delivery is signed with your webhook secret using HMAC-SHA256. Always verify the signature before processing a webhook to confirm it came from Simmit and was not tampered with.

The `X-Simmit-Signature` header has the format:

```
t=1711360000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8f9
```

To verify:

1. Extract the `t` (timestamp) and `v1` (signature) values from the header
2. Concatenate `{t}.{rawRequestBody}` (the timestamp, a literal dot, and the raw JSON body)
3. Compute `HMAC-SHA256` of that string using your webhook secret as the key
4. Compare the hex digest to the `v1` value

**Node.js**

```javascript
import { createHmac, timingSafeEqual } from 'node:crypto'

function verifyWebhook(secret, signatureHeader, rawBody) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => {
      const [k, ...rest] = p.trim().split('=')
      return [k, rest.join('=')]
    })
  )

  if (!parts.t || !parts.v1) {
    throw new Error('Missing required signature fields')
  }

  const timestamp = parts.t
  const expected = parts.v1

  // Reject old timestamps to prevent replay attacks (5 min tolerance)
  const age = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (age > 300) {
    throw new Error('Webhook timestamp too old')
  }

  const computed = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex')

  if (
    computed.length !== expected.length ||
    !timingSafeEqual(Buffer.from(computed), Buffer.from(expected))
  ) {
    throw new Error('Invalid webhook signature')
  }
}
```

**Python**

```python
import hashlib
import hmac
import time

def verify_webhook(secret: str, signature_header: str, raw_body: str):
    parts = dict(
        p.strip().split("=", 1) for p in signature_header.split(",")
    )

    if "t" not in parts or "v1" not in parts:
        raise ValueError("Missing required signature fields")

    timestamp = parts["t"]
    expected = parts["v1"]

    # Reject old timestamps to prevent replay attacks (5 min tolerance)
    age = abs(time.time() - int(timestamp))
    if age > 300:
        raise ValueError("Webhook timestamp too old")

    computed = hmac.new(
        secret.encode(),
        f"{timestamp}.{raw_body}".encode(),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(computed, expected):
        raise ValueError("Invalid webhook signature")
```

> Use a constant-time comparison (e.g. `timingSafeEqual` / `hmac.compare_digest`) to avoid timing attacks.

## Responding to deliveries

Your endpoint should return a `2xx` status code to acknowledge receipt. Non-`2xx` responses trigger retries:

| Your response          | Simmit behavior                         |
| ---------------------- | --------------------------------------- |
| `2xx`                  | Delivery marked successful. No retry.   |
| `408`, `429`, or `5xx` | Retried with exponential backoff.       |
| Other `4xx`            | Treated as permanent failure. No retry. |
| Timeout (>10 s)        | Retried.                                |

> For jobs that are automatically retried by Simmit, the webhook fires only after the final attempt reaches a terminal state, not on intermediate failures.

Use `X-Simmit-Event-Id` for idempotency, since you may receive the same event more than once during retries.

## Rate limits

Webhook deliveries are rate-limited per client on both throughput
and concurrent in-flight requests. Deliveries that exceed these
limits are retried later via exponential backoff. They are not
dropped. Current limits are visible in the dashboard.

## Best practices

- **Respond quickly.** Return `200` immediately and process the payload asynchronously. Long-running handlers risk timeouts and duplicate deliveries.
- **Verify signatures.** Always validate `X-Simmit-Signature` before trusting the payload.
- **Handle duplicates.** Use `X-Simmit-Event-Id` to deduplicate. Retries reuse the same event ID.
- **Use HTTPS.** Your endpoint URL must use HTTPS in production.
- **Keep your secret safe.** Rotate it from the dashboard if it is ever exposed.
- **Fetch results with auth.** Use `payload.id` to fetch the result from `GET /v1/simc/jobs/{id}/result` with your `Authorization` header.

## Example: Express handler

**Node.js**

```javascript
import express from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'

const app = express()

app.post(
  '/webhooks/simmit',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body.toString()
    const signature = req.headers['x-simmit-signature']

    if (!signature || Array.isArray(signature)) {
      return res.status(400).send('Missing or invalid signature header')
    }

    try {
      verifyWebhook(process.env.SIMMIT_WEBHOOK_SECRET, signature, rawBody)
    } catch {
      return res.status(401).send('Invalid signature')
    }

    const event = JSON.parse(rawBody)

    // Acknowledge immediately
    res.status(200).send('ok')

    // Process asynchronously
    if (event.kind === 'job.terminal' && event.payload.status === 'completed') {
      fetchAndProcessResult(event.payload.id)
    }
  }
)
```

**Python**

```python
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/webhooks/simmit")
def handle_simmit_webhook():
    raw_body = request.get_data(as_text=True)
    signature = request.headers.get("X-Simmit-Signature", "")

    try:
        verify_webhook(WEBHOOK_SECRET, signature, raw_body)
    except ValueError:
        abort(401)

    event = request.get_json(silent=True)

    if event["kind"] == "job.terminal" and event["payload"]["status"] == "completed":
        fetch_and_process_result.delay(event["payload"]["id"])

    return "ok", 200
```

---

For the full job lifecycle and result retrieval, see [Sim Job Status](/docs/learning/simc-job-status) and [Sim Job Results](/docs/learning/simc-job-results). For the interactive API schema, see [API Reference](/docs/api-reference).

---

_HTML version: https://simmit.com/docs/api-advanced/webhooks · Full docs index: https://simmit.com/llms.txt_
