Smart Services

by wizewerx

Docs
PricingConsole

Abuse control

Sliding-window rate limits and SSRF-safe URL validation, on by default for every app — a security guard should never be something you opt into.

What it is

Abuse control is two guards for endpoints that spend money on behalf of anonymous callers: an atomic sliding-window rate limiter, and SSRF-safe URL validation that refuses private and reserved addresses. Both are on for every app from the moment it exists — there is no tier, no price and nothing to enable.

Before you start

You need your app's API key. Get it from the Smart Services console: your app → Settings.

The base URL is https://smart-services.io.

Send the key in either header — both are accepted, and Authorization wins if you send both:

Authorization: Bearer <key>
x-api-key: <key>

That is the whole requirement. These routes check your key and nothing else: they deliberately do not gate on a services flag, because an app that cannot protect itself until someone provisions it is an app with an open endpoint.

Your first call

Check and consume a rate limit before doing expensive work. You supply the limits — one entry per tier you want enforced.

curl -X POST "https://smart-services.io/api/v1/ratelimit/check" \
  -H "Authorization: Bearer $SMART_SERVICES_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "keys": [
      { "scope": "ip",     "id": "203.0.113.42", "limit": 3,   "window": 3600 },
      { "scope": "global", "id": "generate",     "limit": 500, "window": 86400 }
    ],
    "cost": 1
  }'

The same call in JavaScript:

const res = await fetch('https://smart-services.io/api/v1/ratelimit/check', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SMART_SERVICES_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    keys: [
      { scope: 'ip', id: '203.0.113.42', limit: 3, window: 3600 },
      { scope: 'global', id: 'generate', limit: 500, window: 86400 },
    ],
    cost: 1,
  }),
});

const verdict = await res.json();
if (!verdict.allowed) {
  // deniedBy names the tier that refused; retryAfter is in seconds
}

Response when the request is allowed:

{
  "allowed": true,
  "deniedBy": null,
  "remaining": { "ip": 2, "global": 499 }
}

And when it is not:

{
  "allowed": false,
  "deniedBy": "ip",
  "retryAfter": 2841,
  "remaining": { "ip": 0, "global": 499 }
}

Note global is still 499 in the denial — nothing was consumed. The check is all-or-nothing across every tier.

The rest of the endpoints

MethodPathWhat it does
POST/api/v1/ratelimit/checkAtomic multi-tier sliding-window check-and-consume. Body { keys[], cost?, namespace? }
POST/api/v1/security/url-checkResolve a URL and refuse private, reserved and non-http(s) targets. Body { url, allowPrivate? }

/security/url-check is stateless and takes no Redis. It returns:

{
  "safe": false,
  "reason": "private-ip",
  "resolvedIp": "169.254.169.254",
  "resolvedIps": ["169.254.169.254"],
  "dnsRebindingWarning": "Validated at check time. A host can re-resolve to a private IP before your fetch — pin resolvedIp in the fetcher to close DNS rebinding."
}

reason is one of bad-scheme, malformed, unresolvable, private-ip, or null when safe is true.

Limits and errors

Errors are JSON with an error string and sometimes details and hint. These two routes do not wrap responses in a success field — read allowed and safe instead.

Status codes: 400 invalid JSON or a rejected argument, 401 missing or invalid key, 503 the rate-limit backend is unreachable.

Validation the rate limiter enforces: at most 10 keys per check; cost an integer between 1 and 1000; every key needs a non-empty string scope and id and positive integer limit and window (seconds); scopes must be distinct across the keys in one call; neither scope nor id may contain :; namespace, if given, is 1–64 characters of [A-Za-z0-9_.-].

Abuse control is untiered and free — it is included with every app and carries no quota of its own. The console lists it under App Settings → Services so you can see it is included, but there is no tier to pick and nothing to buy. Calls you make to the endpoints you are protecting are billed by whatever service those endpoints belong to.

Gotchas

On a backend failure we return 503 and never a silent allow. The fail-open-or-closed decision is yours, because only you know whether a given tier guards a wallet or a real prospect. Fail closed on a global spend ceiling; fail open on a per-IP tier so a Redis blip does not block a genuine visitor. The 503 body carries that hint. Do not treat a failed check as an allow by accident — write the policy down.

The limiter is a sliding window, deliberately. A fixed window lets a caller fire the full limit at 11:59:59 and the full limit again at 12:00:00 — double the intended rate at the boundary. The sorted-set implementation gives a true rolling window.

The check is all-or-nothing across tiers, in one atomic operation. Every key is evaluated before anything is consumed, so a request denied by your global ceiling does not also burn the per-IP budget. Without that, a blocked client that retries would lock itself out of a tier that never denied it.

You supply the id for ip and domain scopes, because we never see your end user. Only your app, behind its own proxy, knows the real client address. Get that wrong — pass your server's address instead of the client's — and every visitor shares one bucket.

namespace defaults to your app id. Two apps that both forget to set one still cannot collide. Set it explicitly when you want separate counter spaces inside one app.

Rate limiting does not stop distributed automation. A botnet with 500 addresses each making 3 requests is invisible to a per-IP limiter and still drains 1,500 expensive calls. Volume from few sources and distributed automation are different problems needing different defences. Size your global ceiling as if the per-IP tier will be bypassed, because it will be.

safe: true is not a promise that your later fetch is safe. The URL check resolves the host at check time. An attacker who controls that host's DNS can return a public address here and a private one moments later when you actually fetch — classic DNS rebinding. Every response carries dnsRebindingWarning precisely so you cannot over-trust the happy path. Closing it requires the fetcher to resolve once and connect to the pinned resolvedIp, keeping the original Host header. That is yours to do, in whatever does the fetching.

All resolved addresses must be public, not just the first. A host resolving to both a public and a private address is refused, because your fetcher might pick the private one. resolvedIps shows you everything it resolved to.

169.254.169.254 is the one that matters. The link-local range contains the cloud metadata endpoint that leaks instance credentials on AWS, GCP and Azure. Loopback, RFC 1918, CGNAT, multicast, IPv6 unique-local and link-local, and IPv4-mapped IPv6 addresses are all refused too.

A URL that cannot be validated fails closed, with HTTP 200. An unresolvable or malformed URL comes back as safe: false with reason: "unresolvable" and a 200 status, because the cost of a false "safe" here is an SSRF. Branch on safe, not on the status code.

Non-http(s) schemes are rejected outrightfile:, gopher:, ftp:, data: are all classic SSRF vectors and never reach DNS resolution.

allowPrivate: true exists, and it turns the guard off for that call. Use it only when you genuinely intend to reach an internal host and the URL is not attacker-controlled.

The same validation is available in-process as src/lib/security/ssrf.ts, which is what Smart Services' own crawlers use. If you are calling from inside the platform, use the lib rather than a round trip.

When a global tier denies, we alert. A denial on an app-wide ceiling is indistinguishable from a successful launch without one. The alert is fired outside the request path, so it never adds latency to the guard or fails it.