Webhooks
Outbound webhooks let you route Roboticks events into anything that accepts HTTP — internal dashboards, ticketing systems, data warehouses, custom Slack apps your security team has standardised on, or your own tooling.
Set up
Add a webhook
Settings → Integrations → Webhooks → Add . Paste your endpoint URL (HTTPS only). Pick a name.
Generate a signing secret
Roboticks generates a 32-byte secret. Copy it once — it’s never shown in plaintext again. Store it where your receiver can read it.
Pick events
Toggle which event types route here. Defaults: all test_run.* and evidence_pack.* events.
Send a test
Hit Send test event . The endpoint receives a synthetic test_run.completed payload.
Payload envelope
Every webhook delivery has the same envelope:
{
"id" : "evt_7c2f..." ,
"type" : "test_run.completed" ,
"occurred_at" : "2026-05-24T12:44:15.221Z" ,
"org" : { "id" : 42 , "slug" : "acme" },
"project" : { "id" : 18 , "slug" : "warehouse" },
"data" : { /* event-type-specific payload, see below */ },
"links" : {
"dashboard" : "https://app.roboticks.io/r/warehouse/runs/8a1f3c2d"
}
}
Event types
test_run.completed
"data" : {
"run_id" : "8a1f3c2d-..." ,
"status" : "failed" ,
"git" : { "sha" : "abcdef123456" , "ref" : "refs/heads/main" , "pr_number" : 214 },
"pool" : { "name" : "prod-gpu-farm" , "type" : "self-hosted" },
"tests" : { "total" : 412 , "passed" : 411 , "failed" : 1 , "skipped" : 0 },
"duration_seconds" : 139 ,
"failures" : [{
"id" : "tests/test_estop.py::test_estop_halts_motion" ,
"message" : "stop reached at 142ms (budget 100ms)" ,
"requirements" : [ "REQ-001" ]
}],
"requirement_coverage" : {
"confirmed" : 142 ,
"uncovered" : 18 ,
"total" : 160 ,
"delta_vs_previous" : { "confirmed" : -1 , "uncovered" : 1 , "stale" : 0 }
}
}
requirement.gap_opened
"data" : {
"requirement_id" : "REQ-051" ,
"title" : "Tool-change interlock" ,
"type" : "safety" ,
"previously_confirmed_by" : "tests/test_toolchange.py::test_interlock" ,
"last_confirmed_run_id" : "8a1a72e0-..." ,
"last_confirmed_at" : "2026-05-21T17:30:00Z" ,
"reason" : "test_deleted" ,
"responsible_commit" : { "sha" : "abcdef123456" , "author" : "amir@roboticks.io" }
}
reason is one of: test_deleted, test_renamed, test_failing, requirement_added, staleness_window_exceeded.
evidence_pack.generated
"data" : {
"pack_id" : "ev_a1b2..." ,
"release_tag" : "v2.4.0" ,
"size_bytes" : 4423012 ,
"formats" : [ "pdf" , "reqif" , "zip" ],
"hash_chain_entries" : 142 ,
"signature" : {
"algorithm" : "cosign" ,
"key_id" : "rbtk-prod-2026"
},
"download_urls" : {
"pdf" : "https://api.roboticks.io/v1/evidence-packs/ev_a1b2/pdf" ,
"reqif" : "https://api.roboticks.io/v1/evidence-packs/ev_a1b2/reqif" ,
"zip" : "https://api.roboticks.io/v1/evidence-packs/ev_a1b2/zip"
}
}
standard.amendment_published
"data" : {
"standard" : "iso-10218-2-2025" ,
"amendment" : "A1" ,
"published_at" : "2026-04-12" ,
"summary" : "Revised §5.7.5 protective-stop performance criteria" ,
"impacted_pinned_requirements" : [ "REQ-001" , "REQ-009" , "REQ-049" ],
"change_impact_report_url" : "https://api.roboticks.io/v1/standards/iso-10218-2-2025/A1/impact"
}
runner_pool.offline
"data" : {
"pool_name" : "onprem-airgapped" ,
"pool_type" : "self-hosted" ,
"airgapped" : true ,
"last_online_at" : "2026-05-24T11:27:01Z" ,
"queued_jobs" : 14
}
Signatures
Every delivery includes an HMAC-SHA256 signature in the X-Roboticks-Signature header:
X-Roboticks-Signature: t=1716561855,v1=abc123...
t is the Unix timestamp the signature was minted (use to defeat replay); v1 is the HMAC.
Verify
Compute HMAC-SHA256 over "{t}.{raw_body}" with your signing secret. Compare to v1 in constant time.
import hmac, hashlib, time
def verify ( raw_body : bytes , sig_header : str , secret : str , tolerance : int = 300 ) -> bool :
parts = dict (p.split( "=" , 1 ) for p in sig_header.split( "," ))
t = int (parts[ "t" ])
if abs (time.time() - t) > tolerance:
return False # outside replay window
signed = f " { t } ." .encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts[ "v1" ])
const crypto = require ( 'crypto' );
function verify ( rawBody , sigHeader , secret , toleranceSec = 300 ) {
const parts = Object . fromEntries (
sigHeader . split ( ',' ). map ( p => p . split ( '=' , 2 ))
);
const t = parseInt ( parts . t , 10 );
if ( Math . abs ( Date . now () / 1000 - t ) > toleranceSec ) return false ;
const signed = Buffer . concat ([ Buffer . from ( ` ${ t } .` ), rawBody ]);
const expected = crypto . createHmac ( 'sha256' , secret ). update ( signed ). digest ( 'hex' );
return crypto . timingSafeEqual (
Buffer . from ( expected ),
Buffer . from ( parts . v1 )
);
}
import (
" crypto/hmac "
" crypto/sha256 "
" encoding/hex "
" strings "
" strconv "
" time "
)
func Verify ( rawBody [] byte , sigHeader , secret string , toleranceSec int64 ) bool {
parts := map [ string ] string {}
for _ , p := range strings . Split ( sigHeader , "," ) {
kv := strings . SplitN ( p , "=" , 2 )
parts [ kv [ 0 ]] = kv [ 1 ]
}
t , _ := strconv . ParseInt ( parts [ "t" ], 10 , 64 )
if abs ( time . Now (). Unix () - t ) > toleranceSec { return false }
mac := hmac . New ( sha256 . New , [] byte ( secret ))
mac . Write ([] byte ( strconv . FormatInt ( t , 10 ) + "." ))
mac . Write ( rawBody )
expected := hex . EncodeToString ( mac . Sum ( nil ))
return hmac . Equal ([] byte ( expected ), [] byte ( parts [ "v1" ]))
}
Always reject deliveries outside the 5-minute timestamp tolerance to defeat replay attacks.
Retry semantics
Failed deliveries (non-2xx response, timeout > 10 s, connection error) retry with exponential backoff:
Attempt Delay 1 immediate 2 30 s 3 5 min 4 30 min 5 4 h 6 24 h
After 6 failed attempts, the delivery is marked failed and a webhook.delivery_failed event fires (you can wire that to Slack to know you’re losing data).
Replays from the dashboard: Settings → Integrations → Webhooks → Delivery log → ⋯ → Resend .
Idempotency
Use the envelope id for idempotency. The same event will never appear under two different id values; the same id may appear more than once if your endpoint timed out and we retried.
INSERT INTO webhook_events (id, type , occurred_at, payload)
VALUES ($ 1 , $ 2 , $ 3 , $ 4 )
ON CONFLICT (id) DO NOTHING;
Best practice
Acknowledge fast: return 2xx within 5 s, then process async. Don’t do heavy work in the request handler.
Verify the signature before parsing JSON.
Log the envelope id for every delivery; correlate with the delivery log on triage.
Reject deliveries whose org.id doesn’t match your expectation (defence-in-depth).
Troubleshooting
Deliveries always retried then failed
Most often the endpoint takes > 10 s to respond. Move processing to a queue and ack immediately.
Signature verification fails for some events
You’re parsing JSON before computing the HMAC. Compute the HMAC over the raw bytes of the request body — even whitespace differences invalidate the signature.
Want to inspect raw deliveries
Use webhook.site or ngrok http 4000 for local development. Both surface the exact headers and body Roboticks sends.