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
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 asen_USores_ES. RACE1 will not guess it. If it isn’t sent and the page doesn’t declare a language, the page is flagged instead. Acceptsen_USoren-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
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-linkrather than looking at the rendered page. - Findings that say
needs_reviewrather thanfixed. 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
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.statusis eitherfixed, applied automatically, orneeds_review, which needs a person. Aneeds_reviewfinding has an emptyafter_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, buthtmlcame back, most rules ran and one didn’t: serve the HTML.
When things go wrong
200: success. Serve the returnedhtml.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 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.
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.