服务器工具启动器服务器列表百科 登录 Discord 聊天 下载
百科目录

在你的网站上接收 AR16 Sentinel 数据

网站如何接收 AR16 Sentinel 从 DayZ 服务器发送的数据:请求、multipart 正文、HMAC 签名、响应与重试、PHP 陷阱、现成的 PHP 接收端和测试发送脚本。

AR16 Sentinel on a game server can send mod data to your website by itself: the folders and files the server owner chose, every time they change. This guide has everything a site needs to accept those deliveries, check their signature and store them, with a ready receiver in PHP and a test sender.

Works with Sentinel 1.0.71 and newer. One POST per source, multipart/form-data, HTTPS only, signed with HMAC-SHA256.

How it works

A game server usually sits behind a router, so a website cannot reach it. The machine opens the connection instead: when a source changes, Sentinel sends all of it to you. No ports and no router settings are needed.

  1. You make a receiving address, for example https://your-site/ar16/receiver.php.
  2. The server owner opens Sentinel, Data feeds, Send to a site, pastes your address and picks the sources.
  3. Sentinel shows a secret ar16s_… once. The owner gives it to you.
  4. Sentinel sends each source when it changes. You check the signature with that secret and answer 200.

Every delivery carries the whole source, not a diff. Keeping the latest version is enough: a new one replaces the old.

The request

HeaderValue
Content-Typemultipart/form-data; boundary=ar16-<32 hex>
X-AR16-Signaturesha256=<64 lowercase hex>, see "The signature"
X-AR16-Timestamptime of sending, Unix seconds (UTC)
X-AR16-Deliveryid of this delivery, 32 lowercase hex; every attempt gets a new one
X-AR16-Feedname of the source, for example tracker
X-AR16-Seqversion of the source; grows whenever its content changes
Content-Encodinggzip, only when "Compress the body" is on for your site in Sentinel
User-AgentAR16-Sentinel/<version>

The POST goes to the address the owner typed. Sentinel does not follow redirects (3xx): the signed body only goes to that address. Your certificate is checked the usual way, so it has to be a real one, for example from Let's Encrypt.

The body

The first part is called meta: the version and the list of files. Then come the files themselves, byte for byte as on disk, as parts f0, f1… in the order of meta.files.

multipart/form-data
--ar16-6f1c…
Content-Disposition: form-data; name="meta"
Content-Type: application/json; charset=utf-8

{"v":1,"feed":"tracker","seq":12,"files":[{"part":"f0","path":"players.json",…}],…}
--ar16-6f1c…
Content-Disposition: form-data; name="f0"; filename="players.json"
Content-Type: application/json

…file bytes…
--ar16-6f1c…
Content-Disposition: form-data; name="f1"; filename="inv/76561198000000001.json"
Content-Type: application/json

…file bytes…
--ar16-6f1c…--

Take the file path from meta, not from filename. PHP and many frameworks keep only the last segment of a file name, so inv/76561198000000001.json becomes 76561198000000001.json. The full path is always in meta.files[i].path for the part named meta.files[i].part.

Fields of meta

FieldMeaning
vformat version, now 1
feed, titlename of the source (Latin letters, digits, - and _, up to 40) and its name for people
kindfolder, file or missing: nothing at that path yet, then files is empty
seq, hashversion and content hash of the source. seq can start again from 1 if the owner deletes the Sentinel folder, so do not refuse a lower seq
changedAt, sentAtwhen the content changed and when this delivery left, ISO 8601 UTC
reasonchanged, interval (an unchanged resend on schedule) or manual (the Send now button)
files[]part, path (inside the source, with /), size, modified, sha256; stale: true when this is the previous version of a file whose new one was half written
skipped[]files left out, with the reason (path, why), for example larger than 8 MB
completefalse when the source hit a limit and arrived incomplete
errorpresent only when the folder could not be read
delivery, intervalSec, count, bytesdelivery id, how often the machine reads the source, number of files and their total size

The signature

The signature proves that the delivery came from the machine the owner gave the secret to, and that nobody changed it on the way.

X-AR16-Signature
X-AR16-Signature = "sha256=" + hex( HMAC-SHA256( key  = the secret as a UTF-8 string, whole, with ar16s_,
                                                 data = X-AR16-Timestamp + "." + X-AR16-Delivery + "." + body ) )
  • The body byte for byte as received. Not after parsing the multipart and not after unpacking gzip: with Content-Encoding: gzip the compressed bytes are signed.
  • Lowercase hex, 64 characters after sha256=.
  • Compare in constant time: hash_equals() in PHP, not ==.
  • Time. Refuse an X-AR16-Timestamp more than 300 seconds away from your clock.
  • Replays. Remember X-AR16-Delivery for about 15 minutes and answer 200 to the same id without doing anything.
PHP
$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $delivery . '.' . $raw, $secret);
if (!hash_equals($expected, $_SERVER['HTTP_X_AR16_SIGNATURE'] ?? '')) { http_response_code(401); exit; }

Answers and retries

AnswerWhen
200stored; also the same delivery again
401wrong signature or time
404unknown address or machine label, if you have them
413larger than your limits
400, 415, 422the body does not parse, is not multipart, or a file's sha256 does not match
429, 503too often, or a temporary storage problem: Sentinel waits and tries again

Any 2xx means accepted. Anything else (another code, a network error, 120 seconds without an answer) is a failure, and Sentinel sends the same latest version again after 10 s, 30 s, 1 min, 5 min, 15 min and then every 15 minutes without end, with ±20% spread. If the source changed meanwhile, the new version comes once you answer again, not all the missed ones. The owner sees your status code and the first 200 characters of your answer in Sentinel, so a short JSON such as {"error":"bad_signature"} tells them what is wrong.

Sentinel sends one delivery at a time to a site, sources in turn. Limits on the machine: a file up to 8 MB, a source up to 32 MiB (33 554 432 bytes of files) and 5 000 files. Do not set your own limits lower, or a large source will get 413 forever.

The PHP trap

With default settings PHP cannot check the signature. For multipart/form-data PHP parses the body into $_POST and $_FILES itself and leaves php://input empty, while the signature covers the raw body. Switch enable_post_data_reading off for the receiver: then php://input gives the whole body, and the receiver parses it itself.

It is a per-directory setting that ini_set() cannot change. Put the receiver in its own folder and switch it off only there:

Where PHP runsHow to switch it off
PHP-FPM or CGI.user.ini in the receiver's folder: enable_post_data_reading = Off. Applies within 5 minutes (user_ini.cache_ttl)
Apache with mod_php.htaccess in the receiver's folder: php_flag enable_post_data_reading Off
A separate FPM poolphp_admin_value[enable_post_data_reading] = Off. Avoid fastcgi_param PHP_VALUE in nginx: the value sticks to the FPM process and reaches other scripts
Built-in server for testingphp -d enable_post_data_reading=0 -S 127.0.0.1:8080

The receiver below answers 400 {"error":"empty_body_check_enable_post_data_reading"} while the setting is still on. Check the sizes too: client_max_body_size 48m in nginx (1 MB by default) or LimitRequestBody in Apache, and a PHP memory_limit of 256M or more, since the body is held in memory several times.

Ready receiver

receiver.php, PHP 8.1 or newer, no dependencies. It checks the time, the signature and replays, unpacks gzip, parses the multipart, checks the sha256 of every file, refuses unsafe paths and swaps the source folder as a whole. Tested on 19 October 2026 against real deliveries from Sentinel 1.0.71: plain, compressed, and with a wrong secret.

  • Put the secret into SECRET, or better read it from an environment variable or a file outside the site.
  • Move STORE outside the public folder of the site: mod files land there as they are.
  • With several machines, give each its own address and secret, for example receiver.php?m=<label> with a folder of its own.
receiver.php
<?php
// receiver.php: receiver for AR16 Sentinel "Send to a site" deliveries.
// PHP 8.1+. This script needs enable_post_data_reading = Off (see "The PHP trap").

const SECRET   = 'ar16s_PASTE_THE_SECRET_FROM_SENTINEL'; // shown once by Sentinel
const STORE    = __DIR__ . '/feeds-data';               // latest version of every source; keep it outside the web root
const MAX_SKEW = 300;                                   // allowed clock difference, seconds
const SEEN_TTL = 900;                                   // how long delivery ids are remembered, seconds

function answer(int $code, array $body): never
{
    http_response_code($code);
    header('Content-Type: application/json');
    echo json_encode($body);
    exit;
}

if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') answer(405, ['error' => 'method']);

// 1. The raw body. Empty with multipart means PHP parsed it itself: set enable_post_data_reading = Off.
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') answer(400, ['error' => 'empty_body_check_enable_post_data_reading']);

// 2. Signature: HMAC-SHA256 with the secret over "<timestamp>.<delivery>." and the body exactly as received.
$ts       = $_SERVER['HTTP_X_AR16_TIMESTAMP'] ?? '';
$delivery = $_SERVER['HTTP_X_AR16_DELIVERY'] ?? '';
$sig      = $_SERVER['HTTP_X_AR16_SIGNATURE'] ?? '';

if (!ctype_digit($ts) || abs(time() - (int)$ts) > MAX_SKEW) answer(401, ['error' => 'stale_or_bad_time']);
if (!preg_match('/^[0-9a-f]{32}$/', $delivery)) answer(400, ['error' => 'bad_delivery_id']);

$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $delivery . '.' . $raw, SECRET);
if (!hash_equals($expected, $sig)) answer(401, ['error' => 'bad_signature']);

// 3. The same delivery again: answer 200 and do nothing.
if (!is_dir(STORE)) mkdir(STORE, 0700, true);
$seenFile = STORE . '/.seen.json';
$seen = is_file($seenFile) ? (json_decode((string)file_get_contents($seenFile), true) ?: []) : [];
$now = time();
$seen = array_filter($seen, fn($t) => $now - $t < SEEN_TTL);
if (isset($seen[$delivery])) answer(200, ['ok' => true, 'duplicate' => true]);
$seen[$delivery] = $now;
file_put_contents($seenFile, json_encode($seen), LOCK_EX);

// 4. gzip ("Compress the body" in Sentinel): unpack only after the signature was checked.
$body = $raw;
if (strtolower($_SERVER['HTTP_CONTENT_ENCODING'] ?? '') === 'gzip') {
    $body = gzdecode($raw);
    if ($body === false) answer(400, ['error' => 'bad_gzip']);
}

// 5. Parse the multipart body.
if (!preg_match('/boundary=([^;\s]+)/', $_SERVER['CONTENT_TYPE'] ?? '', $m)) answer(415, ['error' => 'not_multipart']);
$parts = parse_multipart($body, trim($m[1], '"'));

$meta = json_decode($parts['meta'] ?? '', true);
if (!is_array($meta) || !isset($meta['feed'], $meta['seq'], $meta['files']) || !is_array($meta['files'])) {
    answer(422, ['error' => 'bad_meta']);
}

$feed = (string)$meta['feed'];
if (!preg_match('/^[a-z0-9][a-z0-9_-]{0,39}$/', $feed)) answer(422, ['error' => 'bad_feed']);

// 6. Check every file before writing anything.
foreach ($meta['files'] as $f) {
    $data = $parts[$f['part'] ?? ''] ?? null;
    if ($data === null) answer(422, ['error' => 'missing_part', 'path' => $f['path'] ?? null]);
    if (hash('sha256', $data) !== ($f['sha256'] ?? '')) answer(422, ['error' => 'bad_sha256', 'path' => $f['path'] ?? null]);
    if (!safe_path((string)($f['path'] ?? ''))) answer(422, ['error' => 'bad_path', 'path' => $f['path'] ?? null]);
}

// 7. A source always arrives whole: build a new folder and swap it in.
$dir = STORE . '/' . $feed;
$tmp = $dir . '.new-' . bin2hex(random_bytes(4));
mkdir($tmp, 0700, true);

foreach ($meta['files'] as $f) {
    $target = $tmp . '/' . $f['path'];
    if (!is_dir(dirname($target))) mkdir(dirname($target), 0700, true);
    file_put_contents($target, $parts[$f['part']]);
}
file_put_contents($tmp . '/.meta.json', $parts['meta']);

$old = null;
if (is_dir($dir)) { $old = $dir . '.old-' . bin2hex(random_bytes(4)); rename($dir, $old); }
rename($tmp, $dir);
if ($old !== null) remove_tree($old);

answer(200, ['ok' => true, 'feed' => $feed, 'seq' => $meta['seq'], 'files' => count($meta['files'])]);

// ---- helpers -----------------------------------------------------------------------------------

/** Multipart parts by name. Sentinel makes sure "--<boundary>" never occurs inside a file. */
function parse_multipart(string $body, string $boundary): array
{
    $delim = '--' . $boundary;
    $parts = [];
    $pos = strpos($body, $delim);
    if ($pos === false) return [];
    $pos += strlen($delim);

    while (substr($body, $pos, 2) !== '--') {
        $pos += 2;                                             // \r\n after the delimiter
        $headEnd = strpos($body, "\r\n\r\n", $pos);
        if ($headEnd === false) break;
        $next = strpos($body, "\r\n" . $delim, $headEnd + 4);
        if ($next === false) break;

        $head = substr($body, $pos, $headEnd - $pos);
        if (preg_match('/name="([^"]*)"/', $head, $n)) {
            $parts[$n[1]] = substr($body, $headEnd + 4, $next - $headEnd - 4);
        }
        $pos = $next + 2 + strlen($delim);
    }

    return $parts;
}

/** A relative path inside the source: no "..", no drive and no leading "/". */
function safe_path(string $path): bool
{
    if ($path === '' || $path[0] === '/' || str_contains($path, '\\') || str_contains($path, ':')) return false;
    foreach (explode('/', $path) as $seg) if ($seg === '' || $seg === '.' || $seg === '..') return false;
    return true;
}

function remove_tree(string $dir): void
{
    foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
                 RecursiveIteratorIterator::CHILD_FIRST) as $item) {
        $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
    }
    rmdir($dir);
}

Test sender

To check the receiver before a real machine is connected. send-test.php builds the request exactly like Sentinel: the same multipart, the same headers, the same signature. Needs PHP 8.1+ with curl.

shell
php send-test.php https://your-site/ar16/receiver.php ar16s_YOUR_SECRET            # two test files
php send-test.php https://your-site/ar16/receiver.php ar16s_YOUR_SECRET ./folder   # your own folder
php send-test.php https://your-site/ar16/receiver.php ar16s_YOUR_SECRET --gzip     # compressed

Expected: 200 {"ok":true,"feed":"test","seq":1,"files":2}. With a wrong secret it must be 401 {"error":"bad_signature"}; a 200 there means the signature is not being checked.

send-test.php
<?php
// send-test.php: a test delivery built exactly like AR16 Sentinel builds one. Needs PHP 8.1+ with curl.
//   php send-test.php <receiver url> <secret> [folder] [--gzip]
// Without a folder it sends two test files: players.json and sub/blob.bin.

$args = array_values(array_filter(array_slice($argv, 1), fn($a) => $a !== '--gzip'));
$gzip = in_array('--gzip', $argv, true);
[$url, $secret, $folder] = $args + [null, null, null];

if (!$url || !$secret) {
    fwrite(STDERR, "php send-test.php <url> <secret> [folder] [--gzip]\n");
    exit(2);
}

// Files: path inside the source (with "/") => bytes.
$files = [];
if ($folder) {
    $base = rtrim(realpath($folder), '\\/');
    $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS));
    foreach ($it as $file) {
        if ($file->isFile()) $files[str_replace('\\', '/', substr($file->getPathname(), strlen($base) + 1))] = file_get_contents($file->getPathname());
    }
    ksort($files, SORT_STRING);
} else {
    $files = [
        'players.json' => '{"v":1,"players":[{"name":"Test","hp":100}]}',
        'sub/blob.bin' => "\x00\x01\x02--\r\n\xff",
    ];
}

$delivery = bin2hex(random_bytes(16));
$sentAt = gmdate('Y-m-d\TH:i:s') . '.000Z';
$i = 0;
$list = [];
foreach ($files as $path => $bytes) {
    $list[] = ['part' => 'f' . $i++, 'path' => $path, 'size' => strlen($bytes), 'modified' => $sentAt, 'sha256' => hash('sha256', $bytes)];
}

$meta = json_encode([
    'v' => 1, 'feed' => 'test', 'title' => 'Test', 'kind' => $folder ? 'folder' : 'folder', 'seq' => 1,
    'hash' => hash('sha256', implode("\n", array_column($list, 'sha256'))), 'changedAt' => $sentAt, 'intervalSec' => 60,
    'complete' => true, 'count' => count($files), 'bytes' => array_sum(array_column($list, 'size')), 'skipped' => [],
    'delivery' => $delivery, 'sentAt' => $sentAt, 'reason' => 'manual', 'files' => $list,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

$boundary = 'ar16-' . bin2hex(random_bytes(16));
$body = "--$boundary\r\nContent-Disposition: form-data; name=\"meta\"\r\nContent-Type: application/json; charset=utf-8\r\n\r\n$meta\r\n";
foreach ($list as $f) {
    $body .= "--$boundary\r\nContent-Disposition: form-data; name=\"{$f['part']}\"; filename=\"{$f['path']}\"\r\n"
           . "Content-Type: application/octet-stream\r\n\r\n" . $files[$f['path']] . "\r\n";
}
$body .= "--$boundary--\r\n";

if ($gzip) $body = gzencode($body, 1);

$ts = (string)time();
$signature = 'sha256=' . hash_hmac('sha256', $ts . '.' . $delivery . '.' . $body, $secret);

$headers = [
    "Content-Type: multipart/form-data; boundary=$boundary",
    "X-AR16-Delivery: $delivery",
    "X-AR16-Timestamp: $ts",
    'X-AR16-Feed: test',
    'X-AR16-Seq: 1',
    "X-AR16-Signature: $signature",
    'User-Agent: AR16-Sentinel/test',
];
if ($gzip) $headers[] = 'Content-Encoding: gzip';

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $headers,
    CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 120,
]);
$answer = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);

echo $error ? "error: $error\n" : "$code $answer\n";
echo count($files) . " file(s), " . strlen($body) . " byte(s) sent" . ($gzip ? " (gzip)" : "") . ", delivery $delivery\n";
exit($code >= 200 && $code < 300 ? 0 : 1);

Before connecting

  • The address opens over HTTPS with a real certificate and does not redirect.
  • enable_post_data_reading is off for the receiver's folder.
  • send-test.php with your secret gives 200, with a wrong one 401.
  • The same test with --gzip gives 200.
  • Web server and PHP limits let a body of up to 48 MB through.
  • The folder with received files cannot be opened from a browser.
  • The owner entered the address in Sentinel and you hold the secret from Sentinel. In Sentinel your site's row shows "Delivered at …".

If your site fetches the data itself

There is also the opposite way: your site goes to the machine. It only works when the machine can be reached from the internet. The owner turns on "Hand out data" in Sentinel, forwards a TCP port on the router (8416 by default), opens it in the firewall and gives you the public address, a key ar16f_… and the certificate pin sha256//…. The machine's certificate is self-signed, so the pin is what proves it.

PHP
$ch = curl_init('https://203.0.113.7:8416/v1/feeds/tracker');
curl_setopt_array($ch, [
    CURLOPT_HTTPHEADER      => ['Authorization: Bearer ar16f_…', 'Accept: application/json'],
    CURLOPT_SSL_VERIFYPEER  => false,          // the certificate is self-signed…
    CURLOPT_SSL_VERIFYHOST  => 0,
    CURLOPT_PINNEDPUBLICKEY => 'sha256//…',    // …so the pin is what proves the machine
    CURLOPT_ENCODING        => '',             // accept gzip
    CURLOPT_RETURNTRANSFER  => true,
    CURLOPT_TIMEOUT         => 20,
]);
$feed = json_decode(curl_exec($ch), true);   // { feed, seq, files: [{ path, size, sha256, json | text | base64 }] }

GET /v1/feeds lists the sources the key may read. With If-None-Match and the ETag of the last answer the machine answers 304 without a body when nothing changed. Up to 10 requests a second per key; 20 refusals in 10 minutes close the address for 15 minutes.

Changing files on the machine

With the same port and key your site can also change the files of a source: read one file, set a value inside a JSON file, replace a file whole or delete it. The machine answers at once, in the same request. Works with Sentinel 1.0.74 and newer. Writing is off for every key until the server owner allows it for that key and that source in Data feeds ("May change files in"); a key that may only read gets 403 read_only.

A file lives at /v1/feeds/<source>/files/<path>, the path inside the source written as in the source's own listing. Only the files the source itself hands out can be reached: the ones its filter takes, subfolders only when the source reads them, no links. A new file can be made only where the filter would take it, in a folder that already exists. Anything else answers 404, the same as a file that does not exist.

Nobody overwrites anybody. A read answers with ETag: "<sha256>", the hash of the file's bytes. Every write carries it back in If-Match; If-Match: * is not taken. If the file changed after you read it, the machine answers 412 stale with the file's current ETag and changes nothing: read it again, apply your change to the new version, send again. A new file is made with If-None-Match: * instead.

Setting a value. PATCH with a JSON body {"set": [{"at": "roles[0].canKick", "value": true}]}. at is a path: keys joined by dots, array indexes in brackets. A key that holds a dot or a bracket itself is written as a list, ["roles", "a.b", 0]. The parent has to exist. A new key can be added to an object, a new array item cannot: replace the file for that. All set of one request apply together or none of them. Key order, a BOM and the line endings are kept; the machine indents the JSON with two spaces, so take the new ETag from the answer. The machine reads the source again before it answers, so GET /v1/feeds/<source> already has the change.

write.php
<?php
// write.php: read a file on the machine, change one value in it, then make, replace and delete a file.
// Through the Data feeds port of AR16 Sentinel, with a key the server owner allowed to write to this source.
const BASE = 'https://203.0.113.7:8416/v1/feeds/admin/files/';
const KEY  = 'ar16f_…';
const PIN  = 'sha256//…';

/** One request. Returns [HTTP code, body, ETag of the answer]. */
function call(string $method, string $path, array $headers = [], ?string $body = null): array
{
    $etag = '';
    $ch = curl_init(BASE . implode('/', array_map('rawurlencode', explode('/', $path))));
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST   => $method,
        CURLOPT_HTTPHEADER      => array_merge(['Authorization: Bearer ' . KEY], $headers),
        CURLOPT_SSL_VERIFYPEER  => false,          // the certificate is self-signed…
        CURLOPT_SSL_VERIFYHOST  => 0,
        CURLOPT_PINNEDPUBLICKEY => PIN,            // …so the pin is what proves the machine
        CURLOPT_RETURNTRANSFER  => true,
        CURLOPT_TIMEOUT         => 20,
        CURLOPT_HEADERFUNCTION  => function ($ch, $line) use (&$etag) {
            if (stripos($line, 'etag:') === 0) $etag = trim(substr($line, 5));
            return strlen($line);
        },
    ]);
    if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
    $out = curl_exec($ch);
    if ($out === false) exit('no answer: ' . curl_error($ch) . "\n");
    return [curl_getinfo($ch, CURLINFO_RESPONSE_CODE), $out, $etag];
}

// 1. Read the file. Its ETag is the sha256 of its bytes: the version you are editing.
[$code, $text, $etag] = call('GET', 'permissions.json');
echo "read: $code\n";
if ($code !== 200) exit("$text\n");
$read = $etag;

// 2. Set one value. Everything else in the file stays as it was.
$patch = json_encode(['set' => [['at' => 'roles[0].canKick', 'value' => true]]]);
[$code, $text, $etag] = call('PATCH', 'permissions.json', ['Content-Type: application/json', 'If-Match: ' . $etag], $patch);
echo "patched: $code\n";
if ($code !== 200) exit("$text\n");   // 412 stale: the file changed after you read it. Read it again.

// 3. The same change sent on the version read before is refused, and nothing is written.
[$code, $text] = call('PATCH', 'permissions.json', ['Content-Type: application/json', 'If-Match: ' . $read], $patch);
echo "stale: $code $text\n";

// 4. Make a new file, replace it whole, delete it. Each write answers the new ETag.
[$code, , $etag] = call('PUT', 'notes.json', ['If-None-Match: *'], '{"note": "first"}');
echo "made: $code\n";
[$code, , $etag] = call('PUT', 'notes.json', ['If-Match: ' . $etag], '{"note": "second"}');
echo "replaced: $code\n";
[$code] = call('DELETE', 'notes.json', ['If-Match: ' . $etag]);
echo "deleted: $code\n";

Methods

MethodWhat it doesNeeds
<code>GET</code>, <code>HEAD</code>the file as it is on the disk now, bytes as they are, with ETag
<code>PATCH</code>sets values by JSON path; answers {"sha256": …}
<code>PUT</code>replaces the file whole with the request body
<code>DELETE</code>deletes the file; answers 204

Adding to a number

For balances and counters there is no need to read the file first. Send PATCH with an add list: the machine reads the current value, adds yours and writes the file in one step, under its own lock on the file, so two payments at the same moment both land. A minus value takes away. Works with Sentinel 1.0.76 and newer.

{"add": [{"at": "players[3].balance", "value": -250, "min": 0}]}

  • The value at at has to exist and be a JSON number. A missing key, an index past the end of the array or a value that is not a number is 422 not_number; a missing parent on the way is 422 bad_patch.
  • min and max are optional. A result outside them is refused with 422 out_of_range and nothing is written, so a purchase cannot take a balance below zero.
  • If-Match is not needed for a request with only add. If you send it, it is checked.
  • Idempotency-Key is required, for example the payment id (up to 128 characters, letters, digits and . _ : -). The machine remembers an applied change for 7 days: the same key with the same body returns the first answer again and adds nothing (Idempotency-Replayed: true). So a request you sent again because the answer did not come back is never counted twice. The same key with a different body (other bytes, even the same JSON in another key order) is 422 idempotency_key_reused. A refused change is not remembered, so it is safe to send again.
  • Numbers are added exactly, without the rounding of floating point: 0.1 + 0.2 is 0.3, and the sum keeps as many decimals as the more precise part.
  • Up to 10 requests a second per key. Sending a batch, repeat a 429 with the same Idempotency-Key.
  • The answer is {"sha256": …, "values": [{"at": …, "before": 50, "after": 150}]}.

What the machine cannot fix. If the mod keeps the value in memory and saves it over the file later, your change is lost, as any change made from outside. A mod that handles money this way should take payments as separate files in an inbox folder instead (PUT with If-None-Match: *, one file per payment).

donate.php
<?php
// donate.php: credit a player's balance after a payment and charge it for a purchase, without reading the file first.
// Through the Data feeds port of AR16 Sentinel, with a key the server owner allowed to write to this source.
// Sentinel reads the number, adds and writes it under its own lock, so two payments at once both land.
const BASE = 'https://203.0.113.7:8416/v1/feeds/admin/files/';
const KEY  = 'ar16f_…';
const PIN  = 'sha256//…';

/**
 * Adds $amount (below zero takes away) to the number at $at in $file. $once names this change for good, such as the
 * payment's id: sent again with the same $once, it is answered again and not applied twice. Returns [HTTP code,
 * answer as an array, whether it was a repeat].
 */
function add(string $file, string $at, int|float $amount, string $once, int|float|null $min = null): array
{
    $item = ['at' => $at, 'value' => $amount];
    if ($min !== null) $item['min'] = $min;   // never below this: a charge over the balance is refused, not written

    $replayed = false;
    $ch = curl_init(BASE . implode('/', array_map('rawurlencode', explode('/', $file))));
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST   => 'PATCH',
        CURLOPT_POSTFIELDS      => json_encode(['add' => [$item]]),
        CURLOPT_HTTPHEADER      => ['Authorization: Bearer ' . KEY, 'Content-Type: application/json', 'Idempotency-Key: ' . $once],
        CURLOPT_SSL_VERIFYPEER  => false,          // the certificate is self-signed…
        CURLOPT_SSL_VERIFYHOST  => 0,
        CURLOPT_PINNEDPUBLICKEY => PIN,            // …so the pin is what proves the machine
        CURLOPT_RETURNTRANSFER  => true,
        CURLOPT_TIMEOUT         => 20,
        CURLOPT_HEADERFUNCTION  => function ($ch, $line) use (&$replayed) {
            if (stripos($line, 'idempotency-replayed: true') === 0) $replayed = true;
            return strlen($line);
        },
    ]);
    $out = curl_exec($ch);
    if ($out === false) exit('no answer: ' . curl_error($ch) . "\n");
    return [curl_getinfo($ch, CURLINFO_RESPONSE_CODE), json_decode($out, true), $replayed];
}

// 1. Payment 81723 paid 100: credit it. No read first and no If-Match: the machine adds to what is there now.
[$code, $answer] = add('balances.json', 'players[0].balance', 100, 'payment-81723');
echo "credited: $code ", json_encode($answer['values'] ?? $answer), "\n";

// 2. The answer was lost on the way? Send exactly the same again: the same answer, and the 100 is not added twice.
[$code, $answer, $replayed] = add('balances.json', 'players[0].balance', 100, 'payment-81723');
echo "sent again: $code, replayed ", $replayed ? 'yes' : 'no', ", balance ", $answer['values'][0]['after'], "\n";

// 3. A purchase for 30: take it away, but never below zero.
[$code, $answer] = add('balances.json', 'players[0].balance', -30, 'order-5512', min: 0);
echo "charged: $code ", json_encode($answer['values'] ?? $answer), "\n";

// 4. A purchase for more than the balance: 422 out_of_range, and nothing is written.
[$code, $answer] = add('balances.json', 'players[0].balance', -1000, 'order-5513', min: 0);
echo "too much: $code {$answer['error']}\n";

Write refusals

CodeerrorWhen
404not_found
403read_only
428if_match_required
412stale
415not_json
422bad_patch
413too_large
409io
503busy
428idempotency_key_required
422not_number
422out_of_range
422idempotency_key_reused