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

TypeWhen
file.readyA file passed verification and can be used. Includes metadata and externalReference.
file.rejectedA file failed verification (type mismatch, malware, too large, missing).
session.completedThe visitor finished, or the session closed with accepted files. Includes fileIds.
file.deletedA 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 [email protected].