Webhooks for async run-tests submissions
An async POST /api/run-tests/ submission with a callback URL gets one signed submission.finished event when it finishes or fails. Verify the signature, answer with a 2xx within 10 seconds, and deduplicate on X-OC-Delivery.
When it is sent
Only for async submissions. Send callback_url with the request (it implies async: true), or set a callback URL on the API key and send async: true. A request's own callback_url wins over the key's. The event is made once, when the submission finishes (status: "finished", whatever the verdict) or fails (status: "failed", which means it was not judged: the error says why, such as a deleted or disabled API key, a lower plan, a spent allowance or our side). A submission whose tests could not all start within the start cut-off was not fully judged, so it fails too, with the error "Not every test could start in time. Send the submission again." Send it again, with the same Idempotency-Key if you used one: a failed submission frees its key. A request sent through RapidAPI cannot set a callback_url; look results up instead.
curl -X POST https://api.onlinecompiler.io/api/run-tests/ \
-H "Authorization: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: attempt-4821" \
-d '{
"compiler": "python-3.14",
"code": "a, b = map(int, input().split())\nprint(a + b)",
"tests": [{"input": "1 2\n", "expected_output": "3\n"}],
"callback_url": "https://example.com/hooks/oc",
"external_ref": "attempt-4821"
}'The payload
| Field | Description |
|---|---|
| event | submission.finished. |
| delivery | This event's id, the same as X-OC-Delivery. |
| submission | The id the 202 answer gave. |
| status | finished when the submission was judged, whatever its verdict; failed when it was not judged, including when not every test could start in time. |
| external_ref, metadata | What the request sent, or null. |
| result | When finished: the run-tests response without submission and receipt. null when failed. Hidden tests carry only verdict and hidden, and output file contents are not included (files: [], files_truncated: true). |
| receipt | The signed receipt when finished (see Receipts), or null. |
| error | When failed: why, in plain words. Send the submission again. Otherwise null. |
{
"event": "submission.finished",
"delivery": "dlv_8f2Kq7LmT3xR9vWb1cYd4hNe",
"submission": "sub_Nw4Xc9Pq2Lz7Rt5Vb3Ks8Hm1",
"status": "finished",
"external_ref": "attempt-4821",
"metadata": {"candidate": "c_192"},
"result": {
"verdict": "accepted",
"score": 100,
"passed": 2,
"total": 2,
"time_limit": 2,
"compile_error": null,
"tests": [
{"verdict": "accepted", "hidden": false, "time": "0.0213", "memory": "9000",
"exit_code": 0, "signal": null, "output": "3\n", "error": "", "truncated": false,
"files": [], "files_truncated": false},
{"verdict": "accepted", "hidden": true}
],
"summary": "1 of 1 sample test passed. 1 of 1 hidden test passed."
},
"receipt": {"receipt": "rcpt_7Hq2Zp4Wn8Ks1Ab6Ct3Dv5Ex", "...": "see Receipts"},
"error": null
}Headers
| Header | Value |
|---|---|
| X-OC-Event | The event type: submission.finished. |
| X-OC-Delivery | The event's id, like dlv_8f2Kq7LmT3xR9vWb1cYd4hNe. The same on every retry of the event: deduplicate on it. |
| X-OC-Timestamp | When this attempt was sent, in Unix seconds. Every attempt has a fresh one. |
| X-OC-Signature | v1= and a hex HMAC-SHA256, signed afresh for each attempt. During a secret rotation there are two values, comma separated. |
Verifying the signature
Each API key has a webhook signing secret that starts with whsec_, shown with the key in your dashboard. To check a delivery:
- Take the raw request body as received, before any JSON parsing, and
X-OC-Timestamp. - Compute the hex HMAC-SHA256 of
"{timestamp}.{raw body}", keyed with the whole secret,whsec_prefix included. - Compare
v1=and that value in constant time with each value inX-OC-Signature; one match is enough. After you rotate a secret there are two values for 24 hours, one per secret, and a receiver holding either secret passes. - Reject a timestamp more than about 5 minutes from your clock, which stops a captured request being replayed. Every retry has a fresh timestamp, so a late retry still passes.
import hashlib, hmac, time
def verify(secret, body, headers, tolerance=300):
timestamp = headers["X-OC-Timestamp"]
if abs(time.time() - int(timestamp)) > tolerance:
return False
message = timestamp.encode() + b"." + body # body: the raw bytes as received
expected = "v1=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
sent = headers["X-OC-Signature"].split(",")
return any(hmac.compare_digest(expected, value.strip()) for value in sent)import crypto from 'node:crypto';
// rawBody: a Buffer of the bytes as received; headers: Node's lower-cased header names
export function verify(secret, rawBody, headers, toleranceSeconds = 300) {
const timestamp = headers['x-oc-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const hmac = crypto.createHmac('sha256', secret).update(timestamp + '.').update(rawBody);
const expected = Buffer.from('v1=' + hmac.digest('hex'));
return headers['x-oc-signature'].split(',').some((value) => {
const given = Buffer.from(value.trim());
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}<?php
function verify(string $secret, string $rawBody, string $timestamp, string $signature, int $tolerance = 300): bool {
if (abs(time() - (int) $timestamp) > $tolerance) {
return false;
}
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach (explode(',', $signature) as $value) {
if (hash_equals($expected, trim($value))) {
return true;
}
}
return false;
}
$rawBody = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_OC_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_OC_SIGNATURE'] ?? '';
$ok = verify($secret, $rawBody, $timestamp, $signature);Delivery rules
- The URL must be public: webhooks go only to public addresses, so a private, loopback or link-local address is refused (when you send the request, and again when delivering). A hostname that resolves to a private address at delivery fails the event at once, with no retries.
- Redirects are never followed. A 3xx answer counts as a failed attempt.
- Answer with any 2xx status within 10 seconds. Any other status, no answer in time or no connection is a failed attempt and is retried.
- A disabled API key still receives the events of submissions it sent. When a key is deleted, its pending deliveries stop and its queued submissions fail.
- Do the slow work after you answer: store the event, answer 2xx, then process it.
Retries
A failed event is sent up to 11 times in all. The retries are spaced 1 min, 5 min, 15 min, 30 min, then 1 h, 2 h, 4 h, 8 h, 4 h, 4 h apart, so the last is due 23 h 51 min after the first. They are timed from the first attempt, so an attempt that runs late does not push back the ones after it. An attempt may fire later than its slot, never earlier.
| Attempt | Not sooner than, after the first |
|---|---|
| 1 | at once |
| 2 | 1 min |
| 3 | 6 min |
| 4 | 21 min |
| 5 | 51 min |
| 6 | 1 h 51 min |
| 7 | 3 h 51 min |
| 8 | 7 h 51 min |
| 9 | 15 h 51 min |
| 10 | 19 h 51 min |
| 11 | 23 h 51 min |
Delivery is at least once, so the same event can arrive twice. Every attempt has the same X-OC-Delivery: keep the ids you have handled and ignore a repeat. After the last attempt the event is dropped, but the result is still there: GET /api/submissions/{id}/ returns the same status, result, receipt and error, with created and finished in place of event and delivery, so use it to catch up on events you missed.
Rotating the secret
Press Rotate under Webhook signing secret on the API Keys page of your dashboard. The new secret signs at once, and for 24 hours the old one signs too, so deploy the new secret on your side within that time without dropping events. Rotating again before that ends stops the oldest secret at once.
Questions
- Is a webhook sent for a synchronous run-tests request?
- No. Only an async submission (async: true, or a callback_url) has a webhook. A synchronous request answers with the result in the response.
- Can the same event arrive twice?
- Yes. Delivery is at least once: a retry after a slow or failed answer can arrive after the first was in fact received. Every attempt of an event carries the same X-OC-Delivery, so keep the ids you have handled and ignore repeats.
- What if my server was down?
- The event is retried for about 23 h 51 min. After that, or any time you like, GET /api/submissions/{id}/ returns the same status, result, receipt and error, with created and finished in place of event and delivery.
- Does a disabled or deleted API key still get its events?
- A disabled key still receives the events of submissions it sent. A deleted key's pending deliveries stop, and its queued submissions fail.