Webhooks
Event types, payloads, HMAC signature verification, retries and deduplication for CrossUpload webhooks.
Add endpoints in Dashboard → Webhooks. Each endpoint has a signing secret (whsec_…) shown when created or rotated.
Events
| Type | When |
|---|---|
file.ready | A file passed verification and can be used. Includes metadata and externalReference. |
file.rejected | A file failed verification (type mismatch, malware, too large, missing). |
session.completed | The visitor finished, or the session closed with accepted files. Includes fileIds. |
file.deleted | A file was permanently removed (reason: requested or retention). |
POST https://your-app.example/webhooks/crossupload
Content-Type: application/json
CrossUpload-Event-Id: evt_…
CrossUpload-Signature: t=1762160000,v1=5f2b…
{ "id": "evt_…", "type": "file.ready", "createdAt": "…", "projectId": "prj_…",
"data": { "id": "file_…", "sessionId": "ses_…", "name": "…", "size": 1234, "mimeType": "application/pdf",
"source": "phone", "status": "ready", "expiresAt": "…", "externalReference": "user:17:application:42" } }
Verifying signatures
Compute HMAC-SHA256(secret, "{t}.{raw_body}") in hex and compare it in constant time with v1. Reject timestamps older than five minutes. Use the raw body bytes, before JSON parsing.
use hmac::{Hmac, Mac};
use sha2::Sha256;
fn verify(secret: &str, header: &str, body: &[u8], now: i64) -> bool {
let (mut t, mut v1) = (None, None);
for part in header.split(',') {
match part.split_once('=') { Some(("t", x)) => t = x.parse::<i64>().ok(), Some(("v1", x)) => v1 = Some(x), _ => {} }
}
let (Some(t), Some(v1)) = (t, v1) else { return false };
if (now - t).abs() > 300 { return false; }
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
mac.update(format!("{t}.").as_bytes());
mac.update(body);
let Ok(sig) = hex::decode(v1) else { return false };
mac.verify_slice(&sig).is_ok()
}
Delivery guarantees
- At least once. Deduplicate by the event
id; it is stable across retries. - Any 2xx response is success. Other responses and timeouts (10 s) are retried with bounded exponential backoff (10 s, 20 s, 40 s … up to 1 h), up to eight attempts; then the delivery is marked failed and you can retry it from the dashboard.
- Redirects are not followed. Production endpoints must use HTTPS and resolve to public addresses.
- Events are created in the same database transaction as the state change, so a restart never loses one.
Something unclear or wrong? Email master@crossupload.com.