Skip to content

RACE1 API and Examples

RACE1 is an accessibility service for websites. Send a page’s HTML to one endpoint, and get back the fixed version and a list of findings, each marked as fixed or needing review.

API access comes with any RACE1 plan. See pricing. Save your key when you receive it, because we can’t show it again. Calls must come from your server, never from a browser, so your key stays out of page source.

the request

The request

One endpoint, one request per page:

POST https://race1-api.northfortsoftware.com/v1/process
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json

The body is a JSON object with these fields:

  • html: the full HTML of the page, as your server would send it. Required.
  • url: the page’s address. Used to identify the page in findings. Required.
  • locale: the page’s language, such as en_US or es_ES. RACE1 will not guess it. If it isn’t sent and the page doesn’t declare a language, the page is flagged instead. Accepts en_US or en-US; malformed values are rejected and logged rather than written into your page.
  • strings: optional wording for the text RACE1 adds to pages. See Sending your own wording below.

Each request is one transaction, however many rules run on the page.

examples

Examples

Each example sends a page’s HTML and gets back the fixed version plus a list of findings. Two things matter in any real integration: send the page’s language, and fail open, so that if anything goes wrong, you serve the original HTML.

Examples marked Tested by North Fort have been run against the live API by us. The others are starting points, written to the same pattern.

Most of these are production wiring. PHP, Laravel, Node, TypeScript and Ruby intercept a page on its way to a browser, which means they do nothing useful run from a terminal. Each has a short variation for trying by hand.

Testing any of these

Save a page with some problems in it as test-page.html. Anything will do: a page with images missing alt, a form with no labels, a table using <td> for its header row. Then run the terminal variation for your language and compare:

diff test-page.html test-page-fixed.html

Three things that look like failure and are not:

  • Identical files mean the call failed open and returned your input. That’s correct behavior, but unhelpful while debugging. Add a line to each exit path to find out why.
  • No visible skip link. It’s positioned off-screen until a keyboard user presses Tab, which is what a skip link is for. Search the output for race1-skip-link rather than looking at the rendered page.
  • Findings that say needs_review rather than fixed. RACE1 doesn’t guess. It won’t write alt text, rewrite link text, or add a page language it wasn’t told. Those come back flagged, which is the product working rather than failing.

A worked result on a realistic page: 21 findings, 8 fixed, 13 needing review.

cURL

Tested by North Fort

Nothing to install, nothing to configure. Paste it in a terminal and see a real response. It’s the quickest way to confirm your key works before writing any code.

Requires: nothing.

curl -X POST https://race1-api.northfortsoftware.com/v1/process \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "html": "<html><body><main id=\"content\"><h1>Test</h1></main></body></html>",
        "url": "https://example.com/about",
        "locale": "en_US"
      }'

PHP

Tested by North Fort

The language RACE1 itself is written in. A plain function, plus an output buffer that hands each page over on the way out.

Requires: the cURL extension. Confirm with php -m | grep curl. If it’s missing, see “PHP without the cURL extension” below.

function race1_process(string $html, string $url, string $locale = 'en_US'): string
{
    $ch = curl_init('https://race1-api.northfortsoftware.com/v1/process');
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 8,
        CURLOPT_HTTPHEADER     => [
            'Authorization: Bearer ' . RACE1_API_KEY,
            'Content-Type: application/json',
        ],
        CURLOPT_POSTFIELDS     => json_encode([
            'html'   => $html,
            'url'    => $url,
            'locale' => $locale,
        ]),
    ]);

    $response = curl_exec($ch);
    $status   = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    // 402 means the transaction allowance is used up. Worth logging
    // separately from a network failure. This one you can act on.
    if (402 === $status) {
        error_log('RACE1: allowance exhausted, pages served unmodified.');
        return $html;
    }

    if (false === $response || 200 !== $status) {
        return $html;
    }

    $body = json_decode($response, true);

    return is_array($body) && isset($body['html']) ? $body['html'] : $html;
}

// Buffer the page and hand it over on the way out.
//
// This is the production wiring, and it only does anything under a
// web server: the buffer catches the rendered page on its way to a
// browser. Run this file from a terminal and it processes an empty
// string, because a command-line script produces no page to catch.
ob_start(function (string $html): string {
    $url = 'https://' . $_SERVER['HTTP_HOST'] . $_SERVER['REQUEST_URI'];
    return race1_process($html, $url);
});

Trying it from a terminal

Replace the ob_start() block with this:

$html  = file_get_contents('test-page.html');
$fixed = race1_process($html, 'https://example.com/test-page');

file_put_contents('test-page-fixed.html', $fixed);

echo strlen($html) . ' bytes in, ' . strlen($fixed) . " bytes out\n";

If the sizes match, it failed open. Add echoes to find out why:

if (402 === $status) {
    echo "402: transactions used up\n";
    return $html;
}

if (false === $response || 200 !== $status) {
    echo "status {$status}: {$response}\n";
    return $html;
}

PHP without the cURL extension

Rare, but it happens: a minimal container, or a command-line build installed without it. file_get_contents with a stream context does the same job using nothing but core PHP. It needs allow_url_fopen enabled, which most installations have.

function race1_process(string $html, string $url, string $locale = 'en_US'): string
{
    $context = stream_context_create([
        'http' => [
            'method'  => 'POST',
            'header'  => 'Authorization: Bearer ' . RACE1_API_KEY . "\r\n"
                       . "Content-Type: application/json\r\n",
            'content' => json_encode([
                'html'   => $html,
                'url'    => $url,
                'locale' => $locale,
            ]),
            'timeout' => 15,

            // Without this, any non-2xx response returns false and
            // the body explaining what went wrong is discarded,
            // so a 402 becomes indistinguishable from a network
            // failure. Easy to miss, and it will cost you an hour.
            'ignore_errors' => true,
        ],
    ]);

    $response = @file_get_contents('https://race1-api.northfortsoftware.com/v1/process', false, $context);

    if ($response === false) {
        return $html;
    }

    // The status line is the first header. Clumsier than
    // curl_getinfo(), and the only way to read the code here.
    $status = isset($http_response_header[0])
        ? (int) (explode(' ', $http_response_header[0])[1] ?? 0)
        : 0;

    if ($status !== 200) {
        return $html;
    }

    $body = json_decode($response, true);

    return is_array($body) && isset($body['html']) ? $body['html'] : $html;
}

Same contract, same fail-open behavior. Use the cURL version if you have the choice; it reads better and reports errors more directly.

PHP: Laravel

Starting point

Not a restyled version of the PHP example. Laravel doesn’t use output buffering, so the integration point is middleware wrapping the response: a structurally different shape, and the one most new PHP projects will need.

Requires: Laravel 10 or later. Uses the framework’s own Http facade, so no extra package.

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class Race1Middleware
{
    public function handle(Request $request, Closure $next)
    {
        $response = $next($request);

        // Only full HTML pages. Skip JSON, redirects, downloads.
        if (! str_contains((string) $response->headers->get('Content-Type'), 'text/html')) {
            return $response;
        }

        try {
            $result = Http::withToken(config('race1.key'))
                ->timeout(8)
                ->post('https://race1-api.northfortsoftware.com/v1/process', [
                    'html'   => $response->getContent(),
                    'url'    => $request->fullUrl(),
                    'locale' => str_replace('-', '_', app()->getLocale()),
                ]);

            if ($result->status() === 402) {
                Log::warning('RACE1: allowance exhausted, pages served unmodified.');
                return $response;
            }

            if ($result->successful() && isset($result->json()['html'])) {
                $response->setContent($result->json()['html']);
            }
        } catch (\Throwable $e) {
            // Fail open. Never let this break a page.
            Log::error('RACE1: ' . $e->getMessage());
        }

        return $response;
    }
}

Register it in bootstrap/app.php, or apply it to a route group:

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [\App\Http\Middleware\Race1Middleware::class]);
})

app()->getLocale() gives Laravel’s current locale, so localized routes send the right language automatically.

Trying it from a terminal

Middleware needs a request to wrap, so there’s nothing to run by hand. Load a page in a browser with the middleware registered, then view source. To check the API call itself first, use the plain PHP example, which needs no framework.

JavaScript: Node.js

Starting point

This is Node, not a browser. The API sends no CORS headers, deliberately: calling it from browser JavaScript would put your API key in the page source where anyone can read it.

Requires: Node 18 or later, for built-in fetch. Nothing to install.

async function race1Process(html, url, locale = 'en_US') {
  try {
    const response = await fetch('https://race1-api.northfortsoftware.com/v1/process', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.RACE1_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ html, url, locale }),
      signal: AbortSignal.timeout(8000),
    });

    if (response.status === 402) {
      console.warn('RACE1: allowance exhausted, pages served unmodified.');
      return html;
    }

    if (!response.ok) return html;

    const body = await response.json();
    return body.html ?? html;

  } catch (err) {
    // Fail open on timeout, network error, anything.
    return html;
  }
}

Trying it from a terminal

import { readFile, writeFile } from 'node:fs/promises';

const html  = await readFile('test-page.html', 'utf8');
const fixed = await race1Process(html, 'https://example.com/test-page');

await writeFile('test-page-fixed.html', fixed);
console.log(`${html.length} bytes in, ${fixed.length} bytes out`);

Save as test.mjs and run node test.mjs. Matching sizes mean it failed open; add a console.log to each return path.

TypeScript

Starting point

The same call as the Node.js example, with the response shape declared, which is useful documentation in itself.

Requires: Node 18 or later, and TypeScript. npm i -D typescript tsx, then run with npx tsx test.ts.

interface Finding {
  rule_id: string;
  status: 'fixed' | 'needs_review';
  criterion: string;
  message: string;
  before_snippet: string;
  after_snippet: string;
  element_path: string;
}

interface Race1Response {
  html: string;
  results: Finding[];
  errors: string[];
}

async function race1Process(
  html: string,
  url: string,
  locale = 'en_US',
): Promise<string> {
  try {
    const response = await fetch('https://race1-api.northfortsoftware.com/v1/process', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.RACE1_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ html, url, locale }),
      signal: AbortSignal.timeout(8000),
    });

    if (response.status === 402) {
      console.warn('RACE1: allowance exhausted, pages served unmodified.');
      return html;
    }

    if (!response.ok) return html;

    const body = (await response.json()) as Race1Response;
    return body.html ?? html;

  } catch {
    return html;
  }
}

Trying it from a terminal

Identical to the Node.js version: read a file, call the function, write the result. The types don’t change the runtime behavior.

Ruby

Starting point

Shown as Rack middleware, which is where it belongs in a Rails or Sinatra app.

Requires: nothing. net/http and json are standard library.

require 'net/http'
require 'json'

class Race1Middleware
  def initialize(app)
    @app = app
  end

  def call(env)
    status, headers, body = @app.call(env)

    return [status, headers, body] unless headers['Content-Type']&.include?('text/html')

    html = body.respond_to?(:join) ? body.join : body.to_s
    request = Rack::Request.new(env)

    [status, headers, [race1_process(html, request.url)]]
  end

  private

  def race1_process(html, url, locale = 'en_US')
    uri = URI('https://race1-api.northfortsoftware.com/v1/process')

    request = Net::HTTP::Post.new(uri, {
      'Authorization' => "Bearer #{ENV['RACE1_API_KEY']}",
      'Content-Type'  => 'application/json',
    })
    request.body = { html: html, url: url, locale: locale }.to_json

    response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 8) do |http|
      http.request(request)
    end

    if response.code == '402'
      warn 'RACE1: allowance exhausted, pages served unmodified.'
      return html
    end

    return html unless response.is_a?(Net::HTTPSuccess)

    JSON.parse(response.body)['html'] || html

  rescue StandardError
    # Fail open on anything at all.
    html
  end
end

Trying it from a terminal

The middleware needs a Rack app, so test the inner method directly:

require_relative 'race1_middleware'

html  = File.read('test-page.html')
fixed = Race1Middleware.new(nil).send(:race1_process, html, 'https://example.com/test-page')

File.write('test-page-fixed.html', fixed)
puts "#{html.bytesize} bytes in, #{fixed.bytesize} bytes out"

send is needed because race1_process is private. Fine for a test; don’t do it in application code.

Python

Starting point

Common in build pipelines, static site generators, content tooling, and batch auditing, so it’s shown as a plain function rather than middleware.

Requires: pip install requests.

import os
import requests

def race1_process(html: str, url: str, locale: str = "en_US") -> str:
    try:
        response = requests.post(
            "https://race1-api.northfortsoftware.com/v1/process",
            headers={"Authorization": f"Bearer {os.environ['RACE1_API_KEY']}"},
            json={"html": html, "url": url, "locale": locale},
            timeout=8,
        )

        if response.status_code == 402:
            print("RACE1: allowance exhausted, pages served unmodified.")
            return html

        if response.status_code != 200:
            return html

        return response.json().get("html", html)

    except requests.RequestException:
        # Fail open: timeout, DNS failure, anything.
        return html

Trying it from a terminal

No wiring to replace; the function stands alone. Set RACE1_API_KEY in your environment first.

with open("test-page.html") as f:
    html = f.read()

fixed = race1_process(html, "https://example.com/test-page")

with open("test-page-fixed.html", "w") as f:
    f.write(fixed)

print(f"{len(html)} bytes in, {len(fixed)} bytes out")
the response

The response

A successful response is a JSON object with this shape:

{
  "html": "the fixed page, ready to serve",
  "results": [
    {
      "rule_id": "the rule that found it",
      "status": "fixed",
      "criterion": "the WCAG success criterion",
      "message": "what was found, in plain language",
      "selector_hint": "a selector for finding the element",
      "before_snippet": "the element before",
      "after_snippet": "the element after",
      "element_path": "where it is on the page"
    }
  ],
  "errors": []
}
  • html: serve this in place of the original.
  • results: one entry per finding. status is either fixed, applied automatically, or needs_review, which needs a person. A needs_review finding has an empty after_snippet, because nothing was changed. Store these if you want your own history and reports.
  • element_path: identifies the element across scans, so you can track whether a specific problem persists rather than just counting totals.
  • errors: any problems processing the request. It’s almost always empty. If it isn’t, but html came back, most rules ran and one didn’t: serve the HTML.
when things go wrong

When things go wrong

  • 200: success. Serve the returned html.
  • 400: the request was malformed. Fix it; retrying won’t help.
  • 401: the key is invalid, revoked, or expired. Check the key.
  • 402: the account’s transactions are used up, or service has reached its cap. Serve the original page. It’s worth logging separately, because it’s the one failure you can fix: renew, or add a block. Refused requests aren’t counted.
  • Anything else, including a timeout or network error: serve the original page. A short timeout keeps a slow response from holding up your visitors; our examples use eight seconds.

The rule for every case: RACE1 should never be the reason a page fails to load.

Checking your account: GET /v1/usage

Shows where your account stands. It keeps working even when processing is refused, so it can always tell you why.

{
  "key_display": "4f8a92",
  "plan_type": "monthly",
  "service_state": "active",
  "period_allotment": 200,
  "period_used": 47,
  "period_remaining": 153,
  "block_balance": 5000,
  "total_remaining": 5153,
  "period_started_at": "2026-09-01 00:00:00",
  "may_serve": true,
  "stopped_reason": null
}

total_remaining is the number that matters: your plan’s transactions plus any one-time blocks. period_remaining alone understates what you have.

Checking the service: GET /v1/status

A liveness check. No key needed, and it answers even when other endpoints can’t.

{ "status": "ok", "version": "0.1.0-draft" }
keeping costs down

Keeping your customers’ costs down

The API counts every request and caches nothing, so caching is your client’s job. Done well, it means a site only uses transactions when its content actually changes.

  • Cache by content, not by time. Store the returned HTML alongside a hash of the original. On the next request, if the hash matches, serve the stored result without calling the API. Our WordPress plugin works this way, which is why most page views never reach us.
  • Skip pages that change on every load. Carts, checkouts, and account pages differ for every visitor, so caching can’t help, and they contain visitors’ personal details. Let your users exclude them.
  • Only send full HTML pages. Skip JSON, redirects, files, and anything else that isn’t a page.
your wording

Sending your own wording

RACE1 injects two pieces of text: the skip link, and a title for untitled iframes. Both default to English. Send your own and they’re used exactly as given. RACE1 never translates on your behalf.

{
  "html": "...",
  "url": "https://example.com/es/acerca-de",
  "locale": "es_ES",
  "strings": {
    "skip_link": "Saltar al contenido",
    "iframe_title_suffix": " contenido incrustado"
  }
}

Note the leading space in iframe_title_suffix. It’s added after the embed’s domain, producing “example.com contenido incrustado.”

locale and strings are independent. The API doesn’t check that they agree, because you might have a reason for them to differ.

This is decided per request, so a mostly English site with two Spanish pages just sends different fields for those two URLs. It’s about the language of the page, not where the reader is: someone in Ohio reading a Spanish page needs the same treatment as someone in Mexico City. None of this should ever come from an IP address.