Skip to content

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.

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.

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.

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.

import hashlib
import hmac
import 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")

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.

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.

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: answer 202 again, even if the bytes were serialized differently;
  • same event_id, different fingerprint: answer 409.

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.

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.

  • Send test event: in the admin area, post a signed synthetic event to your URL and see the response. It uses watcher wch_ping and client_reference velocifile-test-ping. Acknowledge it with 202 and 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 simulate to make a real event fire.

Events are stored for 30 days, so you can always recover:

  1. For each watcher, keep the highest sequence you have durably processed.
  2. After an outage, or when you see a gap in sequence numbers, call GET /watchers/{watcher_id}/events?after_sequence=<last> and page until next_after_sequence is null. See the replay sample.
  3. If you get 410 REPLAY_WINDOW_EXPIRED, you have been away longer than 30 days. Rebuild from search, then resume from the watcher’s high_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.

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.