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.
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}"}'{"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.
| Field | Required | What it is |
|---|---|---|
key | One of key or consumer | The API key your customer sent you, if you use keys issued by UsageRail. |
consumer | One of key or consumer | Your 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. |
method | Yes | The HTTP method of your customer's request. |
route | Yes | The 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. |
units | No | A 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:
code | allowed | What to return to your customer |
|---|---|---|
ok | true | Handle the request. |
invalid_api_key | false | 401. The key is malformed, unknown, revoked or expired. |
rate_limited | false | 429 with Retry-After set to retry_after. |
quota_exceeded | false | 429 with Retry-After; retry_after is the seconds left in the period. |
insufficient_credits | false | 402. The prepaid balance is used up. |
consumer_suspended | false | 403. You suspended the customer. |
new_consumer_blocked | false | 403. 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:
| Status | error | What it means |
|---|---|---|
| 400 | invalid_request | The body is malformed, for example units isn't a whole number from 1 to 10,000,000. |
| 401 | invalid_app_key | The app key is wrong. This is your credential, not your customer's. |
| 409 | wrong_integration_mode | The app was created for the SDK. |
| 410 | app_deleted | The app is being deleted. |
| 429 | check_rate_limited, check_quota_exceeded | Your account is over its UsageRail plan's checks per second, or out of its checks for the month. |
| 503 | check_unavailable, check_overloaded | UsageRail 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.
// 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
// 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']);
}