How Vixify works
A plain-language rundown of what actually happens to a webhook, what the real limits are, and how projects are kept isolated from each other. Everything here matches the current code, not a roadmap.
← Back to vixify.app · How this compares to Hookdeck & Svix
1. What happens when a webhook arrives
- Your sender POSTs to
{your-project}.vixify.app(any path). -
If you've turned on signature verification for the project, the request's signature
is checked before anything else -- an invalid or missing one gets rejected with
401and nothing is stored (see section 4). - The raw payload is stored in object storage, and a delivery record is created.
-
You get an immediate
200response, regardless of whether your real target has received it yet -- Vixify owns the delivery from here. - A per-delivery background job POSTs the payload to your target URL -- to every target URL configured on the project, if you have more than one, each tracked (and retried) completely independently.
-
If your target doesn't respond with a
2xxstatus (or doesn't respond at all), it retries with exponential backoff (see section 2) until it succeeds or the plan's retry window runs out.
2. Retries & backoff
-
Backoff formula:
30s × 2^(attempt − 1), capped at 30 minutes between attempts, with a small (±500ms) random jitter so retries don't all land on the exact same tick. That's roughly: 30s, 1m, 2m, 4m, 8m, 16m, then 30m from then on. - Retry window: 48 hours on Free, 7 days on Pro, counted from when the webhook first arrived.
- A delivery's plan (and so its window) is locked in at the moment it arrives. Upgrading mid-retry doesn't retroactively extend a delivery already in progress -- only webhooks received after the upgrade get the longer window.
- Hard cap of 50 attempts regardless of plan, independent of the time-based window -- at the 30-minute backoff ceiling, 7 days would otherwise allow around 330 attempts against a target that's been dead the whole time, which helps nobody.
- Once the window closes (or 50 attempts are used up) without a
2xx, the delivery is marked expired and stops retrying. - Each delivery attempt waits up to 60 seconds for your target to respond -- if it doesn't, the attempt is aborted and treated as a failure like any other, falling into the normal retry/backoff schedule above.
3. Storage & what you can see
- Every attempt's response (status code, headers, and up to 64KB of body) is recorded, not just the final outcome -- useful for debugging a target that's erroring.
- The dashboard shows a project's most recent 100 deliveries.
- A latency chart tracks the average time from
received_atto a successful delivery, per time bucket -- buckets with no successful delivery in them show as a gap in the line rather than a misleading drop to zero. Only successful deliveries count; a delivery still pending or retrying doesn't contribute a latency value until it succeeds. - Retry now resends a delivery to its own original target, reusing the same delivery record and stored payload -- it's the same delivery, just given another attempt immediately instead of waiting for the backoff schedule.
- Replay to… sends a delivery's stored payload to any URL you type in, as a brand-new, independent delivery -- its own id, its own copy of the payload, its own retry schedule. Useful for testing a new or different endpoint against a real past payload without touching the original delivery's history at all.
- On Free, if a delivery expires without ever succeeding, its stored payload and response body are deleted -- the delivery still shows up in your history (status, headers, attempt count), just without the bodies. Pro keeps both regardless of outcome.
- A successfully delivered webhook's payload is kept regardless of plan -- cleanup only ever happens after an unsent delivery expires on Free.
- Up to 5 target URLs per project -- a webhook is fanned out to all of them, each with its own independent retry/backoff. No filtering or routing rules to send different events to different targets (yet).
- Incoming webhook payloads are capped at 10 MiB -- rejected with
413before anything is stored. Comfortably larger than any real-world webhook.
4. Signature verification
Off by default. If you turn it on for a project (from its page in the dashboard), every
incoming request must carry a valid signature or it's rejected with 401
before it's stored anywhere.
- Scheme: HMAC-SHA256, hex-encoded, computed over the raw request body with a secret you set.
- You choose which header carries the signature (default
X-Signature). - An optional prefix can be stripped first, so GitHub-style headers (
sha256=<hex>) work without a named preset -- just set the prefix tosha256=. - The secret itself is write-only: once saved, the dashboard never displays it again.
5. Rate limiting
Both are a different thing from verification, and independent of each other -- a project can have either, both, or neither actually enforcing anything.
- Delivery rate limit: caps how fast outgoing deliveries hit your target, for when your own server can't handle bursts. Off by default. With fan-out, each target gets its own independent cap -- one slow target never eats another's budget.
- Being throttled on delivery doesn't count as a failed attempt and doesn't affect the backoff schedule above -- it just waits a few seconds and checks again, still bounded by the same overall retry window.
- Incoming rate limit: caps how many webhooks per minute are accepted on a project's subdomain at all. Anything over the cap is rejected with
429before it's stored anywhere -- unlike the delivery limit, this one does drop the request rather than queue it, since there's nowhere to buffer an incoming HTTP request. Every new project defaults to 60/min (raise or remove it from the dashboard any time) -- a subdomain can be guessed or enumerated, and unlike the delivery limit this one exists to bound our own storage costs from a flood, not just protect your target. - Both are set per project, per minute, with a fixed one-minute window.
6. Per-target filtering & payload shaping
Both are optional, configured per target (not per project, from that target's "Edit" in the dashboard), and independent of each other -- a target can have a filter, a transform, both, or neither.
- Filter: a dot-path into the payload (parsed as JSON) plus a value it must equal -- e.g. path
typeand valueinvoice.paidroutes only that event type to this target. A target with no filter configured receives every webhook, same as before this feature existed. - Matching is exact-string equality only -- no wildcards, regex, or numeric/boolean comparisons (a number or boolean is compared by its string form). If the payload isn't valid JSON, or the path doesn't resolve to anything, the filter simply doesn't match and the target is skipped for that webhook entirely -- no delivery record is created, the same "never billed for" treatment a rate-limited incoming request gets.
- Transform: a comma-separated list of dot-paths to keep -- e.g.
type,data.object.idreshapes the delivered payload down to just those two fields, with their original nesting preserved. A target with no transform configured gets the full, unmodified body, same as before this feature existed. - A transform runs once, at the moment the webhook is received -- the reshaped body is what's stored and retried, so a manual retry resends exactly what was originally delivered, not a fresh re-transform of the source payload.
- If the payload isn't valid JSON, a configured transform is skipped and the full original body is delivered instead -- unlike a filter mismatch, this fails open rather than silently dropping something you didn't ask to have dropped.
- Dot-paths only traverse JSON objects, not arrays -- there's no way to select "the second item in a list" or similar, only named keys at any nesting depth.
7. Isolation between projects
There's no shared "permissions" table -- every read or write is filtered by ownership at the point it touches the database:
- Every dashboard request is tied to your signed-in session; nothing works without it.
- Listing projects only ever queries for projects you own.
- Every action on a specific project (viewing deliveries, changing settings) first checks that project belongs to you -- if it doesn't, you get a plain "not found," identical to what you'd see for a project id that doesn't exist at all, so guessing ids reveals nothing.
- A delivery can only ever be looked up through a project you already own -- there's no endpoint that takes just a delivery id on its own.
- Incoming webhooks are tied to whichever project owns the subdomain they arrived on -- there's no way for one project's traffic to land in another's history.
Plans, in short
| Free | Pro -- $5/mo | |
|---|---|---|
| Retry window | Up to 48 hours | Up to 7 days |
| Payload/response if delivery never succeeds | Deleted | Kept |
| Signature verification | Included | Included |
| Rate limiting (incoming & delivery) | Included | Included |
| Fan-out (up to 5 targets) | Included | Included |
License & self-hosting
Vixify's source is source-available under the Business Source License 1.1 -- not yet public, but it will be. Once it is, you'll be able to read the code, self-host it, and run it in production for yourself, your own org, or your own customers, entirely free of charge, forever. The one thing the license doesn't permit is standing up a competing hosted webhook-relay service for third parties on top of the code -- for that you'd need a separate commercial license.
- Don't want us to see your webhook traffic at all? Self-hosting is the real answer -- your own Cloudflare account, your own data, nothing passes through vixify.app.
- Want the convenience of not running any infrastructure? The free plan on vixify.app costs nothing and works the same as a self-hosted instance, minus the longer Pro retry window and guaranteed payload retention.
- High volume and don't want to think about Cloudflare limits, migrations, or upkeep? Pro is $5/mo flat -- cheaper than self-hosting is worth your time past a certain point.
Feedback & support
Found a bug, hit a limit that doesn't make sense, or just have feedback? Email kruchkov.alexandr@gmail.com -- it goes straight to the person building this.