﻿# 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. Webhooks are configured per client, and the built-in default client that new keys attach to doesn't accept a webhook — so you'll need a client of your own.

1. Go to [Clients](/clients) and add a client if you don't have one
2. Open the client's **⋯** menu and choose **Configure webhook**
3. Enter your HTTPS endpoint URL and click **Enable webhooks**
4. Copy the signing secret. It is only shown once.

Jobs deliver to the webhook of the client their API key is attached to, so create your key with that client selected on the [API keys](/keys) page.

You can change the URL or disable webhooks from the same menu at any time. Disabling and re-enabling issues a new signing secret.

> 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 at `/account`.

## 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.** If it is ever exposed, disable and re-enable webhooks in the dashboard to issue a new secret.
- **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_
