Webhooks
View as MarkdownWebhooks 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:
- Configure a webhook endpoint on your client (one-time setup in the dashboard)
- 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.
- Go to Clients & Keys and select your client
- Click the Webhook tab
- Enter your HTTPS endpoint URL and click Enable webhooks
- 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:
{
"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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8f9To verify:
- Extract the
t(timestamp) andv1(signature) values from the header - Concatenate
{t}.{rawRequestBody}(the timestamp, a literal dot, and the raw JSON body) - Compute
HMAC-SHA256of that string using your webhook secret as the key - Compare the hex digest to the
v1value
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 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
200immediately and process the payload asynchronously. Long-running handlers risk timeouts and duplicate deliveries. - Verify signatures. Always validate
X-Simmit-Signaturebefore trusting the payload. - Handle duplicates. Use
X-Simmit-Event-Idto 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.idto fetch the result fromGET /v1/simc/jobs/{id}/resultwith yourAuthorizationheader.
#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.