API AND WEBHOOKS
Vortex API and webhooks
Read your prospects, clients and analytics from your own tools, and get a signed call when something happens in your workspace.
Included in the Agency plan. Read-only: the API cannot create, change or delete anything.
Authentication
Create a key in Settings, API and webhooks. It looks like vtx_ followed by 8 characters, an underscore and 32 characters. It is shown once: Vortex only stores a hash of it, so keep it in your password manager. Send it in the Authorization header.
curl https://usevortex.tech/api/v1/prospects \
-H "Authorization: Bearer vtx_xxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"- A key belongs to one workspace and can only read that workspace.
- A revoked key stops working at once. Revoking works on any plan.
- Keys have scopes. The API accepts keys with the api:read scope.
- If the workspace leaves the Agency plan or its subscription stops, every request is refused with 403.
Limits
- 60 requests per minute per key, in fixed one-minute windows. Over the limit you get 429 with a Retry-After header in seconds. Every successful response carries X-RateLimit-Limit.
- Lists return 25 items by default and at most 100 (parameter limit).
- Up to 10 active keys and 5 webhook endpoints per workspace.
Responses and errors
Success: { "data": ... }. Lists add "next_cursor" (null on the last page). Errors: { "error": { "code": "...", "message": "..." } }.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_limit, invalid_cursor, invalid_status, invalid_days | A parameter is not valid |
| 401 | invalid_key | Missing, malformed, unknown or revoked key (always the same answer) |
| 403 | plan_required, subscription_inactive, insufficient_scope, feature_unavailable | The key is valid but the workspace may not use it |
| 404 | not_found | No such object in your workspace |
| 429 | rate_limited | Too many requests |
Endpoints
All endpoints use GET and start with https://usevortex.tech/api/v1.
| Endpoint | Parameters | Returns |
|---|---|---|
| GET /api/v1 | - | Checks the key and describes the API |
| GET /api/v1/prospects | status, limit, cursor | Prospects, newest first |
| GET /api/v1/prospects/{id} | - | One prospect |
| GET /api/v1/clients | limit, cursor | Clients: id, prospect_id, website_url |
| GET /api/v1/clients/{id}/analytics | days (1-90) | Aggregated portal numbers already collected |
| GET /api/v1/payments | status, limit, cursor | Client payments collected through Stripe Connect |
Prospect status values: new, active, standby, client, blacklisted. Payment status values: paid, pending, expired, void, uncollectible, open. Pass next_cursor back as cursor to get the next page.
{
"data": [
{ "id": "…", "status": "new", "city": "Lyon", "niche": "Bakery", "siren": "123456789", "created_at": "2026-10-01T10:00:00.000Z" }
],
"next_cursor": "eyJhdCI6…"
}{ "data": { "client_id": "…", "period": { "days": 30, "from": "…", "to": "…" },
"visitors": 120, "pageviews": 340, "form_submits": 6, "call_clicks": 11,
"conversion_rate_pct": 5, "avg_load_time_ms": 900, "truncated": false } }Payments are what your clients paid you through Vortex (amount_cents, application_fee_cents, currency, status, created_at). Example prospects shown to new accounts are not returned.
Fields that are never returned
Content that comes from Google Places is not exposed by the API or the webhooks: name, address, phone, hours, rating, reviews, google_maps_url. The business name of a client is not exposed either. E-mail addresses, portal links and invoice links are never returned.
Webhooks
Add an endpoint in Settings, API and webhooks, and choose its events. Vortex then sends a POST with a JSON body when the event happens. The signing secret (whsec_…) is shown once when you add the endpoint.
- https:// only, the default port, and a real domain name (no IP address, no localhost, no internal name).
- The name must resolve to public addresses. This is checked again at every delivery, and the connection goes to the address that was checked.
- Redirects are not followed.
Events and payload
| Event | When |
|---|---|
| prospect.created | A prospect is added, by hand or from a search |
| prospect.status_changed | A prospect moves to another pipeline stage (previous_status is included) |
| payment.received | A client payment is settled through Stripe Connect |
| webhook.test | Only when you press "Send a test" |
{
"id": "6f1c1d5e-6b0e-4d0b-9a3a-0e5a1b2c3d4e",
"type": "prospect.created",
"created": "2026-10-23T10:00:00.000Z",
"data": { "id": "…", "status": "new", "city": "Lyon", "niche": "Bakery", "siren": null, "created_at": "…" }
}id is the event id: the same event is never delivered twice to one endpoint, and the id stays the same across retries, so you can use it to deduplicate.
| Header | Value |
|---|---|
| Vortex-Event-Id | The event id |
| Vortex-Event-Type | The event type |
| Vortex-Delivery-Attempt | 1 for the first try |
| Vortex-Signature | t=<unix seconds>,v1=<hex> |
Verifying the signature
v1 is HMAC-SHA256(secret, "<t>.<raw request body>") in hexadecimal. Compute it over the raw body, compare in constant time and reject timestamps older than 5 minutes.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, header, rawBody, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const given = Buffer.from(parts.v1 ?? "");
const wanted = Buffer.from(expected);
return given.length === wanted.length && timingSafeEqual(given, wanted);
}import hashlib, hmac, time
def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts["t"])
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(parts.get("v1", ""), expected)Delivery, retries and failures
- Answer with any 2xx within 10 seconds. Anything else, including a redirect, is a failure.
- A failed delivery is retried after 1 min, 5 min, 30 min, 2 h, 6 h (6 attempts in total). The schedule is stored in the database, so it survives restarts.
- After 10 failed attempts in a row on one endpoint, the endpoint is switched off and shows the reason in Settings. Switching it back on resets the counter.
- The delivery log in Settings keeps the last 30 days: date, event, result, attempts and the HTTP status or a short error code. The response of your server is never stored.