Wiki menu
Receiving AR16 Sentinel data on your website
How a website accepts the data AR16 Sentinel sends from a DayZ server: multipart request, HMAC signature, retries, the PHP trap and a ready PHP receiver.
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.
- You make a receiving address, for example
https://your-site/ar16/receiver.php. - The server owner opens Sentinel, Data feeds, Send to a site, pastes your address and picks the sources.
- Sentinel shows a secret
ar16s_…once. The owner gives it to you. - 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
| Header | Value |
|---|---|
Content-Type | multipart/form-data; boundary=ar16-<32 hex> |
X-AR16-Signature | sha256=<64 lowercase hex>, see "The signature" |
X-AR16-Timestamp | time of sending, Unix seconds (UTC) |
X-AR16-Delivery | id of this delivery, 32 lowercase hex; every attempt gets a new one |
X-AR16-Feed | name of the source, for example tracker |
X-AR16-Seq | version of the source; grows whenever its content changes |
Content-Encoding | gzip, only when "Compress the body" is on for your site in Sentinel |
User-Agent | AR16-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.
--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
| Field | Meaning |
|---|---|
v | format version, now 1 |
feed, title | name of the source (Latin letters, digits, - and _, up to 40) and its name for people |
kind | folder, file or missing: nothing at that path yet, then files is empty |
seq, hash | version 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, sentAt | when the content changed and when this delivery left, ISO 8601 UTC |
reason | changed, 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 |
complete | false when the source hit a limit and arrived incomplete |
error | present only when the folder could not be read |
delivery, intervalSec, count, bytes | delivery 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 = "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: gzipthe compressed bytes are signed. - Lowercase hex, 64 characters after
sha256=. - Compare in constant time:
hash_equals()in PHP, not==. - Time. Refuse an
X-AR16-Timestampmore than 300 seconds away from your clock. - Replays. Remember
X-AR16-Deliveryfor about 15 minutes and answer 200 to the same id without doing anything.
$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
| Answer | When |
|---|---|
200 | stored; also the same delivery again |
401 | wrong signature or time |
404 | unknown address or machine label, if you have them |
413 | larger than your limits |
400, 415, 422 | the body does not parse, is not multipart, or a file's sha256 does not match |
429, 503 | too 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 runs | How 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 pool | php_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 testing | php -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
STOREoutside 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.
<?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.
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 # compressedExpected: 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.
<?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_readingis off for the receiver's folder.send-test.phpwith your secret gives 200, with a wrong one 401.- The same test with
--gzipgives 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.
$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.
<?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
| Method | What it does | Needs |
|---|---|---|
<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
athas 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 is422 not_number; a missing parent on the way is422 bad_patch. minandmaxare optional. A result outside them is refused with422 out_of_rangeand nothing is written, so a purchase cannot take a balance below zero.If-Matchis not needed for a request with onlyadd. If you send it, it is checked.Idempotency-Keyis 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) is422 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.2is0.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
429with the sameIdempotency-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).
<?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
| Code | error | When |
|---|---|---|
404 | not_found | |
403 | read_only | |
428 | if_match_required | |
412 | stale | |
415 | not_json | |
422 | bad_patch | |
413 | too_large | |
409 | io | |
503 | busy | |
428 | idempotency_key_required | |
422 | not_number | |
422 | out_of_range | |
422 | idempotency_key_reused |
