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. 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.
- Go to Clients and add a client if you don't have one
- Open the client's ⋯ menu and choose Configure webhook
- Enter your HTTPS endpoint URL and click Enable webhooks
- 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 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:
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 at /account.
#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. If it is ever exposed, disable and re-enable webhooks in the dashboard to issue a new secret.
- 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.