Documentation

Acrossed API Reference

One endpoint, one decision, sub-millisecond. Every request is HMAC-SHA-256 signed. Rules live in memory. Your traffic data is never stored.

1. Install

npm install acrossed       # Node / TypeScript
pip install acrossed       # Python
go get github.com/acrossed-com/sdk-go  # Go

2. Quickstart — Express

import { createClient } from "acrossed";
import express from "express";

const app = express();
const ac = createClient({
  apiKey:        process.env.ACROSSED_KEY!,
  signingSecret: process.env.ACROSSED_SECRET!,
});

app.use(async (req, res, next) => {
  const result = await ac.checkRequest({
    ip:      req.ip,
    method:  req.method,
    path:    req.path,
    headers: Object.fromEntries(
      Object.entries(req.headers).map(([k, v]) => [k, String(v ?? "")])
    ),
  });
  if (result.decision === "deny") {
    return res.status(403).json({ error: result.reason });
  }
  next();
});

3. Next.js middleware

Drop a middleware.ts at your project root. It runs before RSCs, API routes, and Clerk auth.

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "acrossed";

const ac = createClient({
  apiKey:        process.env.ACROSSED_KEY!,
  signingSecret: process.env.ACROSSED_SECRET!,
  timeoutMs:     800,
});

export async function middleware(req: NextRequest) {
  const ip =
    req.headers.get("x-forwarded-for")?.split(",")[0].trim() ??
    req.headers.get("x-real-ip") ??
    "unknown";

  const result = await ac.checkRequest({
    ip,
    method:  req.method,
    path:    req.nextUrl.pathname,
    headers: Object.fromEntries(req.headers.entries()),
  });

  if (result.decision === "deny") {
    return new NextResponse(
      JSON.stringify({ error: result.reason }),
      { status: 403, headers: { "content-type": "application/json" } }
    );
  }
  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next\/static|_next\/image|favicon.ico).*)"],
};

With Clerk

import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import { createClient } from "acrossed";
import { NextResponse } from "next/server";

const ac = createClient({
  apiKey: process.env.ACROSSED_KEY!,
  signingSecret: process.env.ACROSSED_SECRET!,
});

const isPublic = createRouteMatcher(["/", "/sign-in(.*)", "/sign-up(.*)"]);

export default clerkMiddleware(async (auth, req) => {
  const ip = req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "unknown";
  const result = await ac.checkRequest({ ip, method: req.method, path: req.nextUrl.pathname });
  if (result.decision === "deny") return new NextResponse(null, { status: 403 });
  if (!isPublic(req)) await auth.protect();
});

export const config = {
  matcher: ["/((?!_next\/static|_next\/image|favicon.ico).*)"],
};

4. Fastify

import Fastify from "fastify";
import { createClient } from "acrossed";

const app = Fastify({ trustProxy: true });
const ac = createClient({
  apiKey:        process.env.ACROSSED_KEY!,
  signingSecret: process.env.ACROSSED_SECRET!,
});

// Global hook — runs before every route handler
app.addHook("onRequest", async (req, reply) => {
  const result = await ac.checkRequest({
    ip:      req.ip,
    method:  req.method,
    path:    req.url,
    headers: req.headers as Record<string, string>,
  });
  if (result.decision === "deny") {
    reply.code(403).send({ error: result.reason });
  }
});

5. Edge functions

Vercel Edge Middleware

// middleware.ts (runtime: "edge")
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { createClient } from "acrossed";

export const runtime = "edge";

const ac = createClient({
  apiKey:        process.env.ACROSSED_KEY!,
  signingSecret: process.env.ACROSSED_SECRET!,
  timeoutMs:     600,
});

export async function middleware(req: NextRequest) {
  const ip = req.headers.get("x-forwarded-for")?.split(",")[0].trim() ?? "unknown";
  const result = await ac.checkRequest({ ip, method: req.method, path: req.nextUrl.pathname });
  if (result.decision === "deny") return new NextResponse(null, { status: 403 });
  return NextResponse.next();
}

Cloudflare Workers

import { createClient } from "acrossed";

const ac = createClient({
  apiKey:        ACROSSED_KEY,
  signingSecret: ACROSSED_SECRET,
  timeoutMs:     600,
});

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const ip = request.headers.get("cf-connecting-ip") ?? "unknown";
    const url = new URL(request.url);
    const result = await ac.checkRequest({
      ip,
      method: request.method,
      path:   url.pathname,
      headers: Object.fromEntries(request.headers.entries()),
    });
    if (result.decision === "deny") {
      return new Response(JSON.stringify({ error: result.reason }), {
        status: 403,
        headers: { "content-type": "application/json" },
      });
    }
    return fetch(request);
  },
};

6. Python

Flask

from acrossed import Acrossed
from flask import Flask, request, abort
import os

ac = Acrossed(
    api_key=os.environ["ACROSSED_KEY"],
    signing_secret=os.environ["ACROSSED_SECRET"],
)
app = Flask(__name__)

@app.before_request
def gate():
    d = ac.check_request(request)
    if d.deny:
        abort(403, description=d.reason)

Django middleware

# myapp/middleware.py
from acrossed import Acrossed
from django.http import JsonResponse
import os

class AcrossedMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response
        self.ac = Acrossed(
            api_key=os.environ["ACROSSED_KEY"],
            signing_secret=os.environ["ACROSSED_SECRET"],
        )

    def __call__(self, request):
        ip = (request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip()
              or request.META.get("REMOTE_ADDR", ""))
        result = self.ac.check(ip=ip, method=request.method, path=request.path)
        if result.deny:
            return JsonResponse({"error": result.reason}, status=403)
        return self.get_response(request)

# settings.py — add to MIDDLEWARE list:
# "myapp.middleware.AcrossedMiddleware",

7. Go

package main

import (
    "net/http"
    "os"
    "github.com/acrossed-com/sdk-go"
)

func main() {
    client, err := acrossed.New(acrossed.Config{
        APIKey:        os.Getenv("ACROSSED_KEY"),
        SigningSecret: os.Getenv("ACROSSED_SECRET"),
    })
    if err != nil { panic(err) }

    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("hello"))
    })

    http.ListenAndServe(":8080", gate(client, mux))
}

func gate(c *acrossed.Client, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        result, err := c.Check(r.Context(), acrossed.CheckPayload{
            IP:     r.RemoteAddr,
            Method: r.Method,
            Path:   r.URL.Path,
        })
        if err != nil || result.Decision == "deny" {
            http.Error(w, "Forbidden", http.StatusForbidden)
            return
        }
        next.ServeHTTP(w, r)
    })
}

8. Rule schema

Rules are a JSON array evaluated priority-order (lowest number first). First match wins. Empty array = allow all.

[
  {
    "id":       "login-throttle",
    "priority": 10,
    "match":    { "path": "/login", "method": "POST" },

    "ip_block":       ["1.2.3.4", "10.0.0.0/8"],
    "ip_allow":       ["203.0.113.10"],
    "country_block":  ["RU", "KP"],
    "country_allow":  ["US", "DE"],
    "require_header": "x-auth-token",
    "forbid_header":  "x-bot-flag",

    "time": { "after": "09:00", "before": "21:00", "days": [1,2,3,4,5] },

    "limit": { "requests": 10, "window": "1m", "by": "ip" },

    "action": "deny",
    "reason": "too_many_requests"
  }
]

9. Wire format

If signing requests yourself (not using an SDK), the canonical signing string is timestamp.rawBody and the signature is hex(HMAC-SHA-256(signingSecret, signingString)).

// Required request headers:
X-Acrossed-Key:       <apiKey>
X-Acrossed-Timestamp: <unix seconds as string>
X-Acrossed-Signature: hex(HMAC-SHA256(signingSecret, timestamp + "." + rawBody))
Content-Type:         application/json

// Timestamps older than 10 seconds are rejected.

// Response shape:
{
  "decision":    "allow" | "deny",
  "reason":      "string",
  "matchedRule": "rule-id or undefined",
  "latencyUs":   480
}