Webhooks

View as Markdown

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

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']
    }
  })
})

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
  }
}
FieldDescription
kindEvent type: job.terminal.
versionSchema version: v1.
timestampISO 8601 timestamp of when the event was created.
payload.idThe job ID.
payload.statusTerminal status: completed, failed, cancelled, or timed_out.
payload.statusReasonHuman-readable reason string, or null.

#Request headers

Every webhook delivery includes these headers:

HeaderDescription
X-Simmit-SignatureSignature for verifying authenticity (see below).
X-Simmit-Event-IdUnique ID for the event. Stable across retries.
X-Simmit-Delivery-IdUnique ID for this specific delivery attempt.
Content-TypeAlways 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:

Text
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
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')
  }
}

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 responseSimmit behavior
2xxDelivery marked successful. No retry.
408, 429, or 5xxRetried with exponential backoff.
Other 4xxTreated 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

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)
    }
  }
)

For the full job lifecycle and result retrieval, see Sim Job Status and Sim Job Results. For the interactive API schema, see API Reference.

  • Multistage Executionunderstand how multistage execution culls profilesets across stages.
  • Idempotencyuse idempotency keys to safely retry job submissions.