Skip to content
UsageRail
Frameworks / Other languages

HTTP check for other languages

If your API is written in Go, PHP, Ruby, Java, .NET or anything else without an SDK, call the HTTP check endpoint at the start of each request. UsageRail runs the same checks the SDK runs, the key, the rate limit and the quota, and counts the request if it's allowed. The cost is one network round trip per request.

bash
curl https://api.usagerail.com/v1/check \
  -H "Authorization: Bearer $USAGERAIL_APP_KEY" \
  -H "Content-Type: application/json" \
  -d '{"key": "acme_live_...", "method": "GET", "route": "/v1/items/{id}"}'
json
{"allowed": true, "code": "ok", "limit": 1000, "remaining": 412, "retry_after": null}

Choose HTTP check as the integration when you create the app in the console. You can't switch an app between the SDK and HTTP check later, and an SDK app gets a 409 from this endpoint.

The request

POST /v1/check with the app key as a bearer token. The app key belongs on your server only.

FieldRequiredWhat it is
keyOne of key or consumerThe API key your customer sent you, if you use keys issued by UsageRail.
consumerOne of key or consumerYour own ID for the caller, if you authenticate customers yourself. Send it as is; UsageRail hashes it with your app's secret. A new ID creates a customer on the default plan.
methodYesThe HTTP method of your customer's request.
routeYesThe route template, such as /v1/items/{id}, not the raw path. Route weights and analytics are keyed by method and template, and a raw path never matches a template.
unitsNoA whole number from 1 to 10,000,000. Without it, the route's weight is used, or 1.

The answer

When UsageRail has made a decision, the answer is a 200 with this body:

codeallowedWhat to return to your customer
oktrueHandle the request.
invalid_api_keyfalse401. The key is malformed, unknown, revoked or expired.
rate_limitedfalse429 with Retry-After set to retry_after.
quota_exceededfalse429 with Retry-After; retry_after is the seconds left in the period.
insufficient_creditsfalse402. The prepaid balance is used up.
consumer_suspendedfalse403. You suspended the customer.
new_consumer_blockedfalse403. The app takes no new customers right now, so a new consumer ID isn't created.

limit and remaining describe the rate limit after rate_limited, and the quota otherwise. They're null when there's nothing to describe, such as for a customer whose plan has no limit.

Anything else is a mistake on your side or a problem on ours, and is never counted:

StatuserrorWhat it means
400invalid_requestThe body is malformed, for example units isn't a whole number from 1 to 10,000,000.
401invalid_app_keyThe app key is wrong. This is your credential, not your customer's.
409wrong_integration_modeThe app was created for the SDK.
410app_deletedThe app is being deleted.
429check_rate_limited, check_quota_exceededYour account is over its UsageRail plan's checks per second, or out of its checks for the month.
503check_unavailable, check_overloadedUsageRail can't decide right now.

Counting is different from the SDK

An allowed check counts its units at the moment UsageRail allows it. UsageRail never learns how your request ended, so a request that then fails with a 4xx or 5xx is still counted, and nothing is given back. The SDK only bills 2xx and 3xx responses, because it sees them. If that difference matters for your pricing, use an SDK.

Refused checks (allowed: false) are recorded for analytics but never counted as usage.

When UsageRail doesn't answer

Set a timeout on the call; we suggest 500 ms. On a timeout, a network error, a 429 or a 5xx, your code decides:

  • Let the request through to keep your API up. If UsageRail never recorded the check, the request is never counted.
  • Refuse it with a 503 to never serve a request you couldn't check. If UsageRail did allow and record the check before the answer got lost, it's still counted.

Don't retry a check that timed out. Each call is a new check, and if the retry is allowed too, both count.

How it differs from running the SDK yourself

  • Rate limits are counted once for the whole app, not per server, because every check goes through the same place.
  • A revoked key, a suspended customer or a plan change takes effect within about 2 seconds.
  • Keys are loaded as they're used, so the first check of a key in a while takes slightly longer.

Examples

Two small JSON APIs that check every request and map each code to a response, with the timeout and fallback above. They run in CI against a local check service on every change, so they stay in step with the endpoint. The Go one needs Go 1.22 or newer; the PHP one needs PHP 8.1 or newer with the curl extension.

go/main.go
// A small JSON API that asks UsageRail's check endpoint before it handles each request.
//
//	USAGERAIL_APP_KEY=urk_live_... go run .
//
// Customers send their key as "Authorization: Bearer <key>" or "X-API-Key: <key>".
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

var (
	checkURL = envOr("USAGERAIL_CHECK_URL", "https://api.usagerail.com/v1/check")
	appKey   = os.Getenv("USAGERAIL_APP_KEY")
	// What to do when UsageRail doesn't answer in time: "allow" keeps your API up and the
	// request goes unmetered; "deny" never serves a request it couldn't check.
	onUnavailable = envOr("USAGERAIL_ON_UNAVAILABLE", "allow")
	// 500 ms is the timeout we suggest. Don't retry a timed-out check: if UsageRail did
	// record it, a retry is counted again.
	client = &http.Client{Timeout: 500 * time.Millisecond}
)

type checkResult struct {
	Allowed    bool   `json:"allowed"`
	Code       string `json:"code"`
	Limit      *int64 `json:"limit"`
	Remaining  *int64 `json:"remaining"`
	RetryAfter *int64 `json:"retry_after"`
}

// The status your API returns for each refusal.
var statusFor = map[string]int{
	"invalid_api_key":      http.StatusUnauthorized,
	"rate_limited":         http.StatusTooManyRequests,
	"quota_exceeded":       http.StatusTooManyRequests,
	"insufficient_credits": http.StatusPaymentRequired,
	"new_consumer_blocked": http.StatusForbidden,
	"consumer_suspended":   http.StatusForbidden,
}

// errUnavailable is a timeout, a network error or a 429/5xx from UsageRail: onUnavailable decides.
// Any other non-200 means this server is misconfigured (wrong app key, bad request) and fails loudly.
var errUnavailable = errors.New("usage check unavailable")

func main() {
	if appKey == "" {
		log.Fatal("set USAGERAIL_APP_KEY")
	}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) })
	// route is the template, not the raw path: weights and analytics are per endpoint.
	mux.HandleFunc("GET /v1/items/{id}", protect("/v1/items/{id}", nil, func(w http.ResponseWriter, r *http.Request) {
		writeJSON(w, http.StatusOK, map[string]string{"id": r.PathValue("id")})
	}))
	// Charge by units instead of requests, for example tokens. Here the caller says how many,
	// so the example can also show what an invalid number does.
	mux.HandleFunc("POST /v1/generate", protect("/v1/generate", unitsFromQuery, func(w http.ResponseWriter, r *http.Request) {
		writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
	}))
	addr := ":" + envOr("PORT", "8080")
	log.Printf("listening on %s", addr)
	log.Fatal(http.ListenAndServe(addr, mux))
}

func protect(route string, units func(*http.Request) any, handler http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		key := apiKey(r)
		// No key, or one far longer than any key UsageRail issues: refuse it here. UsageRail
		// would answer 400 for it and not count it either.
		if key == "" || len(key) > 200 {
			writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid_api_key"})
			return
		}
		body := map[string]any{"key": key, "method": r.Method, "route": route}
		if units != nil {
			body["units"] = units(r)
		}
		result, err := check(r.Context(), body)
		switch {
		case errors.Is(err, errUnavailable) && onUnavailable == "allow":
			handler(w, r)
			return
		case errors.Is(err, errUnavailable):
			writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "try_again"})
			return
		case err != nil:
			log.Printf("usage check failed: %v", err)
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal_error"})
			return
		}
		if !result.Allowed {
			if result.RetryAfter != nil {
				w.Header().Set("Retry-After", strconv.FormatInt(*result.RetryAfter, 10))
			}
			status, ok := statusFor[result.Code]
			if !ok {
				status = http.StatusForbidden
			}
			writeJSON(w, status, map[string]string{"error": result.Code})
			return
		}
		handler(w, r)
	}
}

func check(ctx context.Context, body map[string]any) (*checkResult, error) {
	payload, err := json.Marshal(body)
	if err != nil {
		return nil, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, checkURL, bytes.NewReader(payload))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+appKey)
	req.Header.Set("Content-Type", "application/json")
	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", errUnavailable, err)
	}
	defer resp.Body.Close()
	if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
		io.Copy(io.Discard, resp.Body)
		return nil, fmt.Errorf("%w: status %d", errUnavailable, resp.StatusCode)
	}
	if resp.StatusCode != http.StatusOK {
		detail, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		return nil, fmt.Errorf("status %d: %s", resp.StatusCode, detail)
	}
	var result checkResult
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, err
	}
	return &result, nil
}

func apiKey(r *http.Request) string {
	if scheme, key, found := strings.Cut(r.Header.Get("Authorization"), " "); found && strings.EqualFold(scheme, "Bearer") {
		return strings.TrimSpace(key)
	}
	return strings.TrimSpace(r.Header.Get("X-API-Key"))
}

// The number is passed through as given; UsageRail answers 400 for anything that isn't
// a whole number from 1 to 10,000,000, and doesn't count the call.
func unitsFromQuery(r *http.Request) any {
	raw := r.URL.Query().Get("units")
	if raw == "" {
		return 1
	}
	if n, err := strconv.ParseFloat(raw, 64); err == nil {
		return n
	}
	return raw
}

func writeJSON(w http.ResponseWriter, status int, body any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(body)
}

func envOr(name, fallback string) string {
	if value := os.Getenv(name); value != "" {
		return value
	}
	return fallback
}
php/index.php
<?php
// A small JSON API that asks UsageRail's check endpoint before it handles each request.
//
//     USAGERAIL_APP_KEY=urk_live_... php -S localhost:8080 index.php
//
// Customers send their key as "Authorization: Bearer <key>" or "X-API-Key: <key>".
// Needs the curl extension, which most PHP builds include.

declare(strict_types=1);

// The status your API returns for each refusal.
const STATUS_FOR = [
    'invalid_api_key' => 401,
    'rate_limited' => 429,
    'quota_exceeded' => 429,
    'insufficient_credits' => 402,
    'new_consumer_blocked' => 403,
    'consumer_suspended' => 403,
];

// A timeout, a network error or a 429/5xx from UsageRail. USAGERAIL_ON_UNAVAILABLE decides:
// "allow" keeps your API up and the request goes unmetered; "deny" never serves a request it couldn't check.
final class CheckUnavailable extends RuntimeException
{
}

function usage_check(array $body): array
{
    $curl = curl_init(getenv('USAGERAIL_CHECK_URL') ?: 'https://api.usagerail.com/v1/check');
    curl_setopt_array($curl, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR),
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('USAGERAIL_APP_KEY'), 'Content-Type: application/json'],
        CURLOPT_RETURNTRANSFER => true,
        // 500 ms is the timeout we suggest. Don't retry a timed-out check:
        // if UsageRail did record it, a retry is counted again.
        CURLOPT_TIMEOUT_MS => 500,
    ]);
    $response = curl_exec($curl);
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    if ($response === false) {
        throw new CheckUnavailable(curl_error($curl));
    }
    if ($status === 429 || $status >= 500) {
        throw new CheckUnavailable("status $status");
    }
    if ($status !== 200) {
        // Anything else means this server is misconfigured (wrong app key, bad request): fail loudly.
        throw new RuntimeException("status $status: $response");
    }
    return json_decode($response, true, flags: JSON_THROW_ON_ERROR);
}

// $route is the template, not the raw path: weights and analytics are per endpoint.
function protect(string $route, int|float|string|null $units, callable $handler): void
{
    $key = api_key();
    // No key, or one far longer than any key UsageRail issues: refuse it here.
    // UsageRail would answer 400 for it and not count it either.
    if ($key === '' || strlen($key) > 200) {
        send_json(401, ['error' => 'invalid_api_key']);
        return;
    }
    $body = ['key' => $key, 'method' => $_SERVER['REQUEST_METHOD'], 'route' => $route];
    if ($units !== null) {
        $body['units'] = $units;
    }
    try {
        $result = usage_check($body);
    } catch (CheckUnavailable $error) {
        if ((getenv('USAGERAIL_ON_UNAVAILABLE') ?: 'allow') === 'allow') {
            $handler();
        } else {
            send_json(503, ['error' => 'try_again']);
        }
        return;
    } catch (Throwable $error) {
        error_log('usage check failed: ' . $error->getMessage());
        send_json(500, ['error' => 'internal_error']);
        return;
    }
    if (!$result['allowed']) {
        if ($result['retry_after'] !== null) {
            header('Retry-After: ' . $result['retry_after']);
        }
        send_json(STATUS_FOR[$result['code']] ?? 403, ['error' => $result['code']]);
        return;
    }
    $handler();
}

function api_key(): string
{
    $authorization = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
    if (preg_match('/^Bearer\s+(\S+)\s*$/i', $authorization, $match)) {
        return $match[1];
    }
    return trim($_SERVER['HTTP_X_API_KEY'] ?? '');
}

// Charge by units instead of requests, for example tokens. Here the caller says how many, so the
// example can also show what an invalid number does: UsageRail answers 400 for anything that isn't
// a whole number from 1 to 10,000,000, and doesn't count the call.
function units_from_query(): int|float|string
{
    $raw = $_GET['units'] ?? '1';
    return is_numeric($raw) ? $raw + 0 : $raw;
}

function send_json(int $status, array $body): void
{
    http_response_code($status);
    header('Content-Type: application/json');
    echo json_encode($body);
}

if (!getenv('USAGERAIL_APP_KEY')) {
    send_json(500, ['error' => 'set USAGERAIL_APP_KEY']);
    return;
}

$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if ($method === 'GET' && $path === '/healthz') {
    send_json(200, ['ok' => true]);
} elseif ($method === 'GET' && preg_match('#^/v1/items/([^/]+)$#', $path, $match)) {
    protect('/v1/items/{id}', null, fn () => send_json(200, ['id' => $match[1]]));
} elseif ($method === 'POST' && $path === '/v1/generate') {
    protect('/v1/generate', units_from_query(), fn () => send_json(200, ['ok' => true]));
} else {
    send_json(404, ['error' => 'not_found']);
}