Webhooks
When a watcher sees a change, VelociFile sends a signed POST with the event as the
body to your destination URL. Your receiver verifies the signature, stores the event durably, and
answers 202.
Your destination
Section titled “Your destination”Each account has one destination per environment, identified by its delivery_target_id (for example
<your delivery_target_id>). Your own administrators configure its HTTPS URL in the VelociFile admin area. It
is never supplied through the public API. The first time it is configured, a signing secret is
generated and shown once. Administrators can rotate it later and can send a test event from the same
screen.
Because the URL comes from you, every send is checked first:
- HTTPS only, port 443.
- The hostname is resolved at send time. If any address is private, loopback, link-local,
carrier-grade NAT or a cloud metadata address, the delivery stops as failed with
DESTINATION_REJECTED, and no request is made. - Redirects are not followed. A redirect response counts as a failed attempt.
- The request times out after 10 seconds.
Egress IP addresses are not fixed. The signature is what proves a request came from VelociFile, so verify it on every request and do not rely on source IP allowlists.
Headers
Section titled “Headers”| Header | Value |
|---|---|
X-Velocofile-Key-Id |
ID of the signing secret used (for example whk_...). Use it to pick the right secret during a rotation. |
X-Velocofile-Timestamp |
Unix time in seconds when this attempt was signed. |
X-Velocofile-Signature |
v1=<hex>, a lowercase hex HMAC-SHA256. |
Content-Type |
application/json |
The signature is computed over UTF8(timestamp) + "." + body, where body is the exact bytes of the
request body, using the signing secret as the key. Verify against the raw bytes, not a re-serialized
object. Reject a timestamp more than 5 minutes (300 seconds) from your clock to stop replays.
Each retry sends the same body bytes with a fresh timestamp and signature.
Payload
Section titled “Payload”The body is one event, at most 64 KiB:
{ "schema_version": "1.0.0", "event_id": "evt_01", "watcher_id": "wch_01", "watcher_revision": 1, "sequence": 1, "emitted_at": "2026-10-22T16:00:00Z", "observed_at": "2026-10-22T00:00:00Z", "previous_version_id": null, "current_version_id": "fv_0a1b2c3d4e5f60718293a4b5c6d7e8f9", "change_type": "created", "summary": "Illustrative commercial auto filing added to the catalog", "client_reference": "subscription_demo_001", "filing_id": "fil_ABCD-123456789", "filing_version_sequence": 1, "subject_label": "Example Carrier Commercial Auto Program - TX"}summary is plain text of at most 2,000 characters and subject_label at most 200. sequence is
contiguous per watcher, so a gap means you missed an event: recover it by
replay. The same event is returned by the history endpoint with the same content.
Verify the signature
Section titled “Verify the signature”import hashlibimport hmacimport time
def sign(secret: str, timestamp: str, body: bytes) -> str: message = timestamp.encode("utf-8") + b"." + body return "v1=" + hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
def verify(secret: str, timestamp: str, signature: str, body: bytes, tolerance: int = 300) -> bool: try: if abs(time.time() - int(timestamp)) > tolerance: return False expected = sign(secret, timestamp, body) return hmac.compare_digest(expected.encode(), signature.encode()) except (ValueError, TypeError, AttributeError, UnicodeError, OverflowError): return False # malformed headers are rejected, never raised
# Self-check with a made-up secret. In your receiver, pass the request headers and raw body.secret = "whsec_example"body = b'{"event_id":"evt_01"}'ts = str(int(time.time()))assert verify(secret, ts, sign(secret, ts, body), body)assert not verify(secret, ts, sign(secret, ts, body), body + b" ")assert not verify(secret, str(int(ts) - 301), sign(secret, str(int(ts) - 301), body), body)print("ok")import { createHmac, timingSafeEqual } from "node:crypto";
export function sign(secret, timestamp, body) { const hmac = createHmac("sha256", secret); hmac.update(Buffer.from(timestamp, "utf8")); hmac.update("."); hmac.update(body); return `v1=${hmac.digest("hex")}`;}
export function verify(secret, timestamp, signature, body, toleranceSeconds = 300) { const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!Number.isFinite(age) || age > toleranceSeconds) return false; const expected = Buffer.from(sign(secret, timestamp, body)); const received = Buffer.from(signature); return expected.length === received.length && timingSafeEqual(expected, received);}
// Self-check with a made-up secret. In your receiver, pass the request headers and the raw body.const secret = "whsec_example";const body = Buffer.from('{"event_id":"evt_01"}');const ts = String(Math.floor(Date.now() / 1000));if (!verify(secret, ts, sign(secret, ts, body), body)) throw new Error("expected valid");if (verify(secret, ts, sign(secret, ts, body), Buffer.concat([body, Buffer.from(" ")]))) { throw new Error("expected body change to fail");}console.log("ok");In Node frameworks, capture the raw body before any JSON middleware (for example express.raw). In
Python frameworks, read await request.body() and verify before parsing.
What your receiver returns
Section titled “What your receiver returns”| Status | When | VelociFile does |
|---|---|---|
202 |
The event was durably stored, or you already hold an identical copy. | Marks the delivery complete. |
400 |
Malformed request. | Stops. The delivery is marked failed. |
401 |
Invalid or stale signature (INVALID_SIGNATURE). |
Stops and raises an alert. Fix the credentials, then replay. |
409 |
The same event_id arrived with different content. |
Stops and raises an alert. |
413 |
Body over 64 KiB. | Stops. |
429 |
Slow down. Send Retry-After. |
Retries, no sooner than Retry-After. |
5xx, timeout, connection error |
Receiver not ready. | Retries on the schedule below. |
Any other 4xx stops the delivery as failed. Reply with the standard
error envelope for failures.
Duplicates and conflicts
Section titled “Duplicates and conflicts”Delivery is at least once, so you will sometimes get the same event twice. Store events by
(account, environment, event_id). Compute a fingerprint as the SHA-256 of the UTF-8 RFC 8785 (JCS)
canonical form of the event object:
- same
event_id, same fingerprint: answer202again, even if the bytes were serialized differently; - same
event_id, different fingerprint: answer409.
Acknowledgement is not publication. A 202 means the event is safely stored. Process it from your own
queue.
Events for a paused watcher are held and delivered after it resumes, so they can arrive late and in a
burst. Use sequence to order them.
Retry schedule
Section titled “Retry schedule”After the first attempt, a failed delivery (network error, 429, 5xx) is retried at:
| Retry | Delay after the first attempt |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 6 hours |
| 6 | 12 hours |
| 7 | 24 hours |
Bounded random jitter is added to each delay, and a Retry-After from your receiver is
honored. After the 24 hour retry the delivery is marked failed. Every attempt is logged and visible in
the admin area.
Test your receiver
Section titled “Test your receiver”- Send test event: in the admin area, post a signed synthetic event to your URL and see the response. It
uses watcher
wch_pingandclient_referencevelocifile-test-ping. Acknowledge it with202and ignore it. It is never stored as a real event. - Sandbox reference receiver and simulator: see Environments. Use the reference
receiver to see a correct implementation, and
simulateto make a real event fire.
Replay recovery
Section titled “Replay recovery”Events are stored for 30 days, so you can always recover:
- For each watcher, keep the highest
sequenceyou have durably processed. - After an outage, or when you see a gap in sequence numbers, call
GET /watchers/{watcher_id}/events?after_sequence=<last>and page untilnext_after_sequenceisnull. See the replay sample. - If you get
410 REPLAY_WINDOW_EXPIRED, you have been away longer than 30 days. Rebuild from search, then resume from the watcher’shigh_watermark.
Stopped deliveries (for example after a 401 from a wrong secret) are also recoverable: fix the
cause, then read the events from the history endpoint.
Rotating the signing secret
Section titled “Rotating the signing secret”When an administrator rotates the secret, the new one gets a new key ID, and VelociFile signs with the
newest. The previous secret stays valid for you to verify with for 24 hours. Look up the secret by
X-Velocofile-Key-Id, so both work during the overlap. The new secret is shown once.