<!-- SentinelShare developer docs, all pages. Links starting with /developers are pages of this document; the API base URL is https://sentinelshare.com/api/v1/integration. -->

<!-- Source: https://sentinelshare.com/developers -->

# SentinelShare developer docs

SentinelShare is a shared ban network for Arma Reforger communities. Server staff file bans for cheating, exploiting, duping, racism and DDoS threats, other communities verify them, and every member community can use the list. These docs show your developers how to bring that list into your own tools.

## What you can build

| You want to | Use | Start here |
|---|---|---|
| Show network bans inside your admin panel | The admin panel API: a change feed your panel syncs from | [Admin panel API](/developers/admin-panel-api) |
| Check players when they join your game server or panel | The lookup endpoint | [Admin panel API](/developers/admin-panel-api#look-up-a-player) |
| Do it in a Laravel or PHP panel | A ready-made sync command and join check | [Laravel and PHP](/developers/laravel) |
| Do it in Node.js or Python | Copy-paste sync loops and lookups | [Node.js and Python](/developers/node-and-python) |
| Let your staff check, add and remove bans from Discord | The official SentinelShare bot | [Add the SentinelShare bot](/developers/discord-bot) |
| Find banned players already in your Discord | `/bans scan` with the official bot; the report is saved on the website | [Scan your Discord](/developers/discord-bot#4-scan-your-discord-for-banned-members) |
| Warn your staff when a banned player joins your Discord | Staff alerts from the official bot, or your own bot calling the lookup endpoint | [Staff alerts](/developers/discord-bot#3-staff-alerts-and-new-ban-posts), [Build your own Discord bot](/developers/build-a-discord-bot) |

## Before you start

1. **Your community needs to be on SentinelShare.** A SentinelShare network admin adds your community and makes your server owner a **Server Owner** account. Ask a SentinelShare network admin.
2. **Your server owner creates an API key.** They sign in at [sentinelshare.com](https://sentinelshare.com) with Discord, open **API keys** and press **Create key**. Keys start with `ssk_`, are read-only, and belong to your community, so they keep working when staff change.
3. **Give the key to your developer privately.** Anyone with it can read every ban on the network, including players' IP addresses. Keep it in server-side config, never in a browser, a public repo or a screenshot.

## The API in one minute

- Base URL: `https://sentinelshare.com/api/v1/integration`
- Send your key on every request: `Authorization: Bearer ssk_…`
- `GET /bans` returns bans created or changed since your last call. Store them by `id`, replace on repeat, and call again with `since=<next_cursor>` every few minutes.
- `GET /lookup?arma_uid=…` (or `discord_id`, `ip`, `name`) returns one player and all their bans.
- 300 requests a minute per key.

## Giving these docs to an AI assistant

Every page has **Download .md** and **Copy as Markdown** buttons. To hand the whole set to an AI coding assistant in one go:

- [Download all the docs as one file](/llms-full.txt) and attach or paste it into your assistant, then describe your panel: framework, database, where players join.
- Tools that read `llms.txt` can start from [sentinelshare.com/llms.txt](/llms.txt).

A good prompt to start with: *"Using the attached SentinelShare docs, add a scheduled job to my Laravel admin panel that syncs SentinelShare bans into a `sentinel_bans` table, and flag players on join whose Arma UID has an active ban."*

## Getting help

- The machine-readable API reference is at [/api/v1/docs](/api/v1/docs).
- Questions, or a field you need that isn't there: ask a SentinelShare network admin.
- What changed and when: [Changelog](/developers/changelog).

---

<!-- Source: https://sentinelshare.com/developers/admin-panel-api -->

# Admin panel API

The admin panel API gives your community's admin panel, game server mod or bot every ban on the SentinelShare network, kept up to date. Your tools decide what to do with them: flag, kick, or just show staff the history. It is read-only.

- Base URL: `https://sentinelshare.com/api/v1/integration`
- Auth: `Authorization: Bearer ssk_…` (a community key, see [Get a key](#get-a-key))
- Format: JSON, UTF-8. Times are ISO 8601 in UTC.

## Get a key

Keys belong to a community, not a person, so they keep working when staff change. A server owner creates them on SentinelShare under **API keys**. Make one per panel or tool, so you can revoke one without breaking the rest.

A key is shown once, when it's created. It can read every ban on the network, including players' IP addresses, and can't change anything. Keep it in your panel's server-side config (an environment variable or secrets file), never in browser code or a public repo. If one leaks, revoke it and create another.

## Authenticate

Send the key as a bearer token on every request:

```http
GET /api/v1/integration/bans HTTP/1.1
Host: sentinelshare.com
Authorization: Bearer ssk_your_key_here
```

A missing, wrong or revoked key gets `401`.

## Sync bans

`GET /bans` returns bans created or changed since your last call, oldest change first.

| Parameter | Meaning |
|---|---|
| `since` | The `next_cursor` from your previous response. Omit it for a full sync. |
| `limit` | Bans per page, 1 to 500. Default 200. |

The response:

```jsonc
{
  "items": [ /* ban records, see "The ban record" below */ ],
  "next_cursor": "MjAyNi0xMC0yNlQxNjowNjoyNS44MzgrMDA6MDB8NzRlOWM1NzA…",
  "has_more": false
}
```

How to use it:

1. **First run:** leave out `since`. Keep calling with the returned `next_cursor` until `has_more` is `false`. That pages through every ban on the network.
2. **Save `next_cursor`** somewhere that survives restarts (a database row or file).
3. **Every few minutes:** call with `since=<saved cursor>`. Usually you get nothing back and the same cursor.
4. **Store bans by `id`** and replace the copy you have when a ban comes back. A ban comes back whenever it changes: edited, lifted, verified or denied, or when its player gets a new identifier or is merged with another profile.

Changes show up in the feed a few seconds after they happen. A cursor never goes stale; if you lose it, drop `since` and sync from the start (records are replaced by `id`, so nothing duplicates).

## Look up a player

`GET /lookup` checks one player, for example when they join your server. Pass any identifiers you have; the Arma UID is the strongest match.

| Parameter | Meaning |
|---|---|
| `arma_uid` | Their Arma Reforger UID |
| `discord_id` | Their Discord user ID (the long number) |
| `ip` | An IP address they connected from |
| `name` | Part of their in-game name, at least 2 characters |

```bash
curl -G https://sentinelshare.com/api/v1/integration/lookup \
  -d arma_uid=f91f26e6-aea4-4822-b26b-c9135f3c3b66 \
  -H "Authorization: Bearer $SENTINEL_KEY"
```

```jsonc
{
  "found": true,
  "player": {
    "id": "b421bf42-0b9d-419d-a116-661852a6d496",
    "alias": "Halvorsen",
    "arma_uids": ["f91f26e6-aea4-4822-b26b-c9135f3c3b66"],
    "discord_ids": ["347530151542738677"],
    "ips": ["203.0.113.7"],
    "names": ["Halvorsen", "Halv"]
  },
  "url": "https://sentinelshare.com/subjects/b421bf42-0b9d-419d-a116-661852a6d496",
  "bans": [ /* every ban on this player, newest first, same shape as the feed */ ],
  "other_matches": [
    { "id": "4c49145e-…", "alias": "Halv0rsen", "url": "https://sentinelshare.com/subjects/4c49145e-…",
      "matched_on": ["ip"], "active_bans": 1 }
  ]
}
```

A player with no record returns `{"found": false, "player": null, "url": null, "bans": [], "other_matches": []}`.

When several profiles match, the best one is `player` and the rest are in `other_matches`, with `matched_on` saying how they matched.

## The ban record

The feed and lookup return bans in one shape, so you can store either. Fields are only ever added, never renamed or removed, so ignore fields you don't know.

```json
{
  "id": "239c7d3f-ccb3-4762-880b-bcc84d677c9b",
  "url": "https://sentinelshare.com/subjects/b421bf42-0b9d-419d-a116-661852a6d496",
  "status": "active",
  "type": "CHEATING",
  "reason": "Aimbot confirmed while spectating",
  "evidence": ["https://medal.tv/clips/…"],
  "filed_at": "2026-10-12T18:04:00Z",
  "filed_by": { "name": "Ryan", "community": "Iron Wolf Milsim" },
  "verification": {
    "state": "PEER_VERIFIED",
    "history": [
      { "action": "PEER_VERIFY", "by": "Mara", "community": "Northshore RP",
        "notes": "Same clip on our server", "at": "2026-10-13T09:12:44Z" }
    ]
  },
  "player": {
    "id": "b421bf42-0b9d-419d-a116-661852a6d496", "alias": "Halvorsen",
    "arma_uids": ["f91f26e6-…"], "discord_ids": ["3475…"],
    "ips": ["203.0.113.7"], "names": ["Halvorsen", "Halv"]
  },
  "other_matches": [
    { "id": "4c49145e-…", "alias": "Halv0rsen", "url": "https://sentinelshare.com/subjects/4c49145e-…",
      "matched_on": ["ip"], "active_bans": 1 }
  ],
  "updated_at": "2026-10-13T09:12:44Z"
}
```

| Field | Meaning |
|---|---|
| `id` | The ban's ID. Store bans by it; a changed ban comes back with the same ID. |
| `url` | The player's profile on SentinelShare. Opening it needs a staff sign-in. |
| `status` | `active`, or `lifted` when the ban was removed or an appeal was accepted. |
| `type` | `CHEATING`, `EXPLOITING`, `DUPLICATING`, `TOXICITY_TROLLING`, `RACISM` or `DDOS` (DDoS threats and doxxing). |
| `reason`, `evidence` | What the filing staff member wrote, and their evidence links. |
| `filed_at` | When the ban was filed. |
| `filed_by` | The staff member who filed it and their community. |
| `verification.state` | `UNVERIFIED`, `PEER_VERIFIED` (another community vouched), `PENDING_STAFF_REVIEW`, `STAFF_VERIFIED` or `DENIED`. |
| `verification.history` | Every verification step, oldest first. |
| `player` | Everything known about the player: Arma UIDs, Discord IDs, IP addresses and names, main one first. |
| `other_matches` | Other profiles that may be the same person. `arma_uid` / `discord_id`: flagged as a possible duplicate. `ip`: shares an IP address. Up to 20. |
| `updated_at` | When anything in this record last changed. |

## Deciding what to do with a ban

SentinelShare doesn't tell you to ban anyone; your community decides. Common rules:

- **Kick or block** players with an `active` ban whose verification is `PEER_VERIFIED` or `STAFF_VERIFIED`.
- **Flag for staff** players with an `active` but `UNVERIFIED` ban, or with `other_matches` that have active bans.
- **Ignore** `lifted` bans and anything `DENIED`, but keep them in your history.

## Limits and errors

Each key can make 300 requests a minute. Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. Errors come back as JSON with a `detail` message.

| Status | Why | What to do |
|---|---|---|
| `401` | No key, a wrong key, a revoked key, or your community was deactivated | Check the key; ask your server owner for a new one |
| `400` | The `since` cursor isn't one we gave you | Drop `since` and sync from the start |
| `422` | A lookup without any identifier, or a value that's too long | Pass at least one of `arma_uid`, `discord_id`, `ip`, `name` |
| `429` | Over 300 requests a minute for this key | Wait the number of seconds in the `Retry-After` header |
| `5xx` | SentinelShare is having trouble | Retry with backoff; your stored bans still work |

---

<!-- Source: https://sentinelshare.com/developers/laravel -->

# Laravel and PHP

A complete setup for a Laravel admin panel: keep a local copy of every SentinelShare ban, refresh it every five minutes, and check players against it when they join. At the end there is a plain-PHP lookup if you don't use Laravel.

Works on Laravel 10 and 11 with MySQL, MariaDB or PostgreSQL. You need a community API key (see [Admin panel API](/developers/admin-panel-api#get-a-key)).

## 1. Configuration

Add the key to `.env` (never commit it):

```bash
SENTINEL_API_URL=https://sentinelshare.com/api/v1/integration
SENTINEL_API_KEY=ssk_your_key_here
```

And to `config/services.php`:

```php
'sentinelshare' => [
    'url' => env('SENTINEL_API_URL', 'https://sentinelshare.com/api/v1/integration'),
    'key' => env('SENTINEL_API_KEY'),
],
```

## 2. A table for network bans

`php artisan make:migration create_sentinel_bans_table`, then:

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('sentinel_bans', function (Blueprint $table) {
            $table->uuid('id')->primary();                 // SentinelShare ban id
            $table->uuid('player_id')->index();
            $table->string('player_alias');
            $table->json('arma_uids');
            $table->json('discord_ids');
            $table->string('status', 16)->index();         // active | lifted
            $table->string('type', 32);                    // CHEATING, EXPLOITING, ...
            $table->string('verification_state', 32);      // UNVERIFIED, PEER_VERIFIED, ...
            $table->text('reason')->nullable();
            $table->string('filed_by')->nullable();
            $table->string('filed_by_community')->nullable();
            $table->timestamp('filed_at')->nullable();
            $table->timestamp('changed_at')->nullable();   // SentinelShare's updated_at
            $table->json('record');                        // the full record as received
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('sentinel_bans');
    }
};
```

## 3. The model

`app/Models/SentinelBan.php`:

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class SentinelBan extends Model
{
    public $incrementing = false;
    protected $keyType = 'string';
    protected $guarded = [];

    protected $casts = [
        'arma_uids' => 'array',
        'discord_ids' => 'array',
        'record' => 'array',
        'filed_at' => 'datetime',
        'changed_at' => 'datetime',
    ];

    /** Columns for one ban record from the SentinelShare API. */
    public static function attributesFrom(array $r): array
    {
        return [
            'player_id' => $r['player']['id'],
            'player_alias' => $r['player']['alias'],
            'arma_uids' => $r['player']['arma_uids'],
            'discord_ids' => $r['player']['discord_ids'],
            'status' => $r['status'],
            'type' => $r['type'],
            'verification_state' => $r['verification']['state'],
            'reason' => $r['reason'],
            'filed_by' => $r['filed_by']['name'],
            'filed_by_community' => $r['filed_by']['community'],
            'filed_at' => $r['filed_at'],
            'changed_at' => $r['updated_at'],
            'record' => $r,
        ];
    }

    public function scopeActive(Builder $query): Builder
    {
        return $query->where('status', 'active');
    }

    public function scopeVerified(Builder $query): Builder
    {
        return $query->whereIn('verification_state', ['PEER_VERIFIED', 'STAFF_VERIFIED']);
    }

    /** SentinelShare stores Arma UIDs in lower case. */
    public function scopeForArmaUid(Builder $query, string $armaUid): Builder
    {
        return $query->whereJsonContains('arma_uids', strtolower(trim($armaUid)));
    }

    public function scopeForDiscordId(Builder $query, string $discordId): Builder
    {
        return $query->whereJsonContains('discord_ids', trim($discordId));
    }
}
```

## 4. The API client

`app/Services/SentinelShare.php`:

```php
<?php

namespace App\Services;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

class SentinelShare
{
    private function http(): PendingRequest
    {
        return Http::baseUrl(config('services.sentinelshare.url'))
            ->withToken(config('services.sentinelshare.key'))
            ->acceptJson()
            ->timeout(15)
            ->retry(3, 5000);   // covers brief outages and 429s
    }

    /** Bans created or changed since the cursor. */
    public function bans(?string $since = null, int $limit = 200): array
    {
        return $this->http()
            ->get('/bans', array_filter(['since' => $since, 'limit' => $limit]))
            ->throw()
            ->json();
    }

    /** One player and all their bans. Pass any of arma_uid, discord_id, ip, name. */
    public function lookup(array $identifiers): array
    {
        return $this->http()->get('/lookup', array_filter($identifiers))->throw()->json();
    }
}
```

## 5. The sync command

`php artisan make:command SyncSentinelBans`, then:

```php
<?php

namespace App\Console\Commands;

use App\Models\SentinelBan;
use App\Services\SentinelShare;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;

class SyncSentinelBans extends Command
{
    protected $signature = 'sentinel:sync {--full : Ignore the saved cursor and sync everything}';
    protected $description = 'Pull new and changed bans from SentinelShare';

    public function handle(SentinelShare $sentinel): int
    {
        $cursor = $this->option('full') ? null : Cache::get('sentinelshare.cursor');
        $saved = 0;

        do {
            $page = $sentinel->bans($cursor);

            foreach ($page['items'] as $ban) {
                SentinelBan::updateOrCreate(['id' => $ban['id']], SentinelBan::attributesFrom($ban));
                $saved++;
            }

            $cursor = $page['next_cursor'];
            if ($cursor) {
                Cache::forever('sentinelshare.cursor', $cursor);
            }
        } while ($page['has_more']);

        $this->info("Saved {$saved} bans.");

        return self::SUCCESS;
    }
}
```

Run it once by hand: `php artisan sentinel:sync`. The first run pages through every ban on the network.

If your cache gets cleared the next run starts over from the beginning. That's safe (bans are replaced by `id`), just slower. To keep the cursor through cache clears, store it in a database table instead.

## 6. Run it every five minutes

Laravel 11, in `routes/console.php`:

```php
use Illuminate\Support\Facades\Schedule;

Schedule::command('sentinel:sync')->everyFiveMinutes()->withoutOverlapping();
```

Laravel 10, in `app/Console/Kernel.php`:

```php
protected function schedule(Schedule $schedule): void
{
    $schedule->command('sentinel:sync')->everyFiveMinutes()->withoutOverlapping();
}
```

Both need Laravel's scheduler in the server's crontab:

```bash
* * * * * cd /var/www/your-panel && php artisan schedule:run >> /dev/null 2>&1
```

## 7. Check players when they join

Check the local table first: it's instant and costs no API calls. Put this wherever your panel learns a player connected (a game-server webhook, a log watcher, a queue job):

```php
use App\Models\SentinelBan;

$bans = SentinelBan::active()->forArmaUid($armaUid)->get();

if ($bans->isEmpty()) {
    return; // nothing on the network
}

if (SentinelBan::active()->verified()->forArmaUid($armaUid)->exists()) {
    // Verified network ban: kick, block, or alert staff - your community's rule
} else {
    // Active but unverified: flag the player for staff to look at
}
```

For identifiers you don't sync, such as a Discord ID from your whitelist form, ask the API directly:

```php
$result = app(\App\Services\SentinelShare::class)->lookup(['discord_id' => $discordId]);

if ($result['found']) {
    $active = collect($result['bans'])->where('status', 'active');
    // $result['url'] links staff to the player's SentinelShare profile
}
```

## 8. Show it on a player page

A Blade partial for your player view:

```blade
@php($networkBans = \App\Models\SentinelBan::forArmaUid($player->arma_uid)->orderByDesc('filed_at')->get())

@if ($networkBans->isNotEmpty())
  <div class="alert alert-warning">
    <strong>SentinelShare:</strong>
    {{ $networkBans->where('status', 'active')->count() }} active network ban(s)
    <ul>
      @foreach ($networkBans as $ban)
        <li>
          {{ ucfirst(strtolower(str_replace('_', ' ', $ban->type))) }},
          {{ $ban->filed_by_community }}, {{ $ban->filed_at?->format('d M Y') }},
          {{ $ban->status }}, {{ strtolower(str_replace('_', ' ', $ban->verification_state)) }}
          <a href="{{ $ban->record['url'] }}" target="_blank" rel="noopener">profile</a>
        </li>
      @endforeach
    </ul>
  </div>
@endif
```

## Plain PHP (no framework)

A lookup with cURL:

```php
<?php

function sentinel_lookup(array $identifiers): array
{
    $url = 'https://sentinelshare.com/api/v1/integration/lookup?' . http_build_query(array_filter($identifiers));
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 10,
        CURLOPT_HTTPHEADER => [
            'Authorization: Bearer ' . getenv('SENTINEL_API_KEY'),
            'Accept: application/json',
        ],
    ]);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    if ($status !== 200) {
        throw new RuntimeException("SentinelShare lookup failed: HTTP {$status}");
    }

    return json_decode($body, true);
}

$result = sentinel_lookup(['arma_uid' => 'f91f26e6-aea4-4822-b26b-c9135f3c3b66']);
echo $result['found'] ? count($result['bans']) . " bans\n" : "clean\n";
```

---

<!-- Source: https://sentinelshare.com/developers/node-and-python -->

# Node.js and Python

Copy-paste examples for the two things most panels need: a sync loop that keeps a local copy of every ban, and a lookup when a player joins. They use only built-in libraries plus `requests` for Python. Swap the `saveBan` / `save_ban` functions for your own database.

See [Admin panel API](/developers/admin-panel-api) for every field and error.

## Node.js (18 or newer)

Set `SENTINEL_KEY` in the environment, then run with `node sentinel-sync.mjs`.

```js
// sentinel-sync.mjs: keeps a local copy of every SentinelShare ban
import { readFile, writeFile } from "node:fs/promises";

const API = "https://sentinelshare.com/api/v1/integration";
const HEADERS = { Authorization: `Bearer ${process.env.SENTINEL_KEY}` };
const CURSOR_FILE = "./sentinel-cursor.txt";

async function api(path, params = {}) {
  const url = new URL(API + path);
  for (const [k, v] of Object.entries(params)) if (v) url.searchParams.set(k, v);
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(url, { headers: HEADERS });
    if (res.status === 429) {
      const wait = Number(res.headers.get("retry-after") ?? 10);
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    if (!res.ok) throw new Error(`SentinelShare ${path}: HTTP ${res.status} ${await res.text()}`);
    return res.json();
  }
  throw new Error("SentinelShare: still rate limited");
}

async function saveBan(ban) {
  // Replace with an upsert into your database, keyed by ban.id
  console.log(ban.status, ban.type, ban.player.alias, ban.verification.state);
}

export async function syncOnce() {
  let cursor = await readFile(CURSOR_FILE, "utf8").catch(() => null);
  let page;
  do {
    page = await api("/bans", { since: cursor, limit: 200 });
    for (const ban of page.items) await saveBan(ban);
    if (page.next_cursor) {
      cursor = page.next_cursor;
      await writeFile(CURSOR_FILE, cursor);
    }
  } while (page.has_more);
}

export async function lookup(identifiers) {
  // identifiers: { arma_uid?, discord_id?, ip?, name? }
  return api("/lookup", identifiers);
}

// Sync now, then every five minutes
await syncOnce();
setInterval(() => syncOnce().catch(console.error), 5 * 60 * 1000);
```

A join check with the same helper:

```js
const result = await lookup({ arma_uid: "f91f26e6-aea4-4822-b26b-c9135f3c3b66" });
const active = result.found ? result.bans.filter((b) => b.status === "active") : [];
const verified = active.filter((b) => ["PEER_VERIFIED", "STAFF_VERIFIED"].includes(b.verification.state));
if (verified.length) console.log(`Verified network ban: ${result.url}`);
else if (active.length) console.log(`Unverified network ban, flag for staff: ${result.url}`);
```

## Python (3.9 or newer)

`pip install requests`, set `SENTINEL_KEY`, then `python sentinel_sync.py`.

```python
# sentinel_sync.py: keeps a local copy of every SentinelShare ban
import os
import time
from pathlib import Path

import requests

API = "https://sentinelshare.com/api/v1/integration"
SESSION = requests.Session()
SESSION.headers["Authorization"] = f"Bearer {os.environ['SENTINEL_KEY']}"
CURSOR_FILE = Path("sentinel-cursor.txt")


def api(path: str, **params) -> dict:
    params = {k: v for k, v in params.items() if v}
    for _ in range(5):
        res = SESSION.get(API + path, params=params, timeout=15)
        if res.status_code == 429:
            time.sleep(int(res.headers.get("Retry-After", 10)))
            continue
        res.raise_for_status()
        return res.json()
    raise RuntimeError("SentinelShare: still rate limited")


def save_ban(ban: dict) -> None:
    # Replace with an upsert into your database, keyed by ban["id"]
    print(ban["status"], ban["type"], ban["player"]["alias"], ban["verification"]["state"])


def sync_once() -> None:
    cursor = CURSOR_FILE.read_text().strip() if CURSOR_FILE.exists() else None
    while True:
        page = api("/bans", since=cursor, limit=200)
        for ban in page["items"]:
            save_ban(ban)
        if page["next_cursor"]:
            cursor = page["next_cursor"]
            CURSOR_FILE.write_text(cursor)
        if not page["has_more"]:
            break


def lookup(**identifiers) -> dict:
    """Pass any of arma_uid, discord_id, ip, name."""
    return api("/lookup", **identifiers)


if __name__ == "__main__":
    while True:
        try:
            sync_once()
        except requests.RequestException as exc:
            print("sync failed:", exc)
        time.sleep(300)
```

A join check:

```python
result = lookup(arma_uid="f91f26e6-aea4-4822-b26b-c9135f3c3b66")
active = [b for b in result["bans"] if b["status"] == "active"] if result["found"] else []
verified = [b for b in active if b["verification"]["state"] in ("PEER_VERIFIED", "STAFF_VERIFIED")]
if verified:
    print("Verified network ban:", result["url"])
elif active:
    print("Unverified network ban, flag for staff:", result["url"])
```

## Storing bans

Whatever database you use, the pattern is the same:

- One row per ban, keyed by `id`. Insert or replace on every record you receive.
- Keep `status`, `type`, `verification.state`, `player.arma_uids` and `player.discord_ids` as columns so you can query them, and the whole record as JSON for everything else.
- Save `next_cursor` after every page, not just at the end, so a crash mid-sync doesn't start over.

---

<!-- Source: https://sentinelshare.com/developers/discord-bot -->

# Add the SentinelShare bot

The SentinelShare bot lets your staff check, file, lift and verify bans from your own Discord and scan everyone in it for network bans. It warns your staff when a banned player joins or a member gets banned elsewhere, and can post every new network ban to a channel you pick. Nothing to host or code: invite it and give your staff access.

## How access works

Every command runs **as the person who typed it**, using their own SentinelShare account and role. Bans they file show their name; lifts and verifications are recorded under them. Someone without a SentinelShare account gets "Your Discord account isn't linked to a SentinelShare account".

A SentinelShare account is tied to a Discord account, so your staff don't need passwords: the bot and the website both recognise them by Discord.

## 1. Invite the bot

Someone with **Manage Server** in your Discord opens this link and picks your server:

[Invite the SentinelShare bot](https://discord.com/oauth2/authorize?client_id=1477829010817028260&permissions=18432&scope=bot%20applications.commands)

It asks for **Send Messages** and **Embed Links** only. The commands appear in your server straight away.

## 2. Give your staff access

Your community's **Server Owner** account (set up by a SentinelShare network admin) adds staff from Discord:

```text
/staff add member:@Alex role:Moderator
```

| Role | Can |
|---|---|
| Moderator | Check bans, file bans, lift your community's bans, verify other communities' bans |
| Read-Only | Check bans |

Server owners can give Moderator or Read-Only. Ask a SentinelShare network admin for another Server Owner.

- `/staff remove member:@Alex` takes access away. Bans they filed stay on record.
- `/staff list` shows who in your community has access, with links to their SentinelShare profiles.
- `/whoami` shows anyone their own account, role and what they can do.

## 3. Staff alerts and new-ban posts

The bot can post to two channels in your Discord:

| Channel | What gets posted |
|---|---|
| **Staff alerts** | Someone with an active SentinelShare ban joins your Discord. Another community bans someone who is already a member. Each alert names the member and shows their bans, so make it a staff-only channel. |
| **New bans** (optional) | Every new ban on the network, within about a minute. |

Your **Server Owner** sets them up in either of two ways:

- **On the website:** open [sentinelshare.com](https://sentinelshare.com), go to **Communities**, pick your community and open the **Discord** tab. Paste your Discord server ID and the channel IDs. To copy an ID, turn on Developer Mode in Discord (Settings, Advanced), then right-click the server or channel.
- **In Discord:** run these in the channels you want:

```text
/setup alerts channel:#staff-alerts
/setup notifications channel:#sentinelshare-bans
```

Both save to the same place, and the bot picks up changes within a minute. Make sure it can send messages and embed links in those channels.

- Alerts count any active ban, verified or not; the alert shows each ban's verification status so staff can judge.
- Your own community's bans don't alert you; your staff filed them.
- Join alerts and member alerts need Discord's **Server Members Intent** on the bot, the same as `/bans scan`.

## 4. Scan your Discord for banned members

Anyone on your staff with **Ban Members** or **Manage Server** in your Discord runs:

```text
/bans scan
```

The bot checks every member of that Discord (bots are skipped) against SentinelShare by Discord ID:

1. Straight away it replies with a link to the report, which fills in on the website as the scan runs.
2. Members are checked 1,000 at a time with a short pause between batches, so a large server takes a minute or two. The reply shows how far it has got.
3. When it's done, the reply says how many members have SentinelShare bans and how many of those are still active, lists the first ten, and links the full report.

The full report is under **Reports** on [sentinelshare.com](https://sentinelshare.com). For every member with a ban it shows their name in your Discord, their Discord ID, the SentinelShare player profile they match, how many bans are active, the ban types and which communities filed them. You can filter it to active bans only, copy the Discord IDs, or download it as a CSV.

- Reports are visible to staff of the community that ran the scan, and to SentinelShare network admins. Other communities can't see your member list.
- Only members who match a SentinelShare player are saved. Everyone else is checked and not stored.
- A Discord can have one scan running at a time. If a scan stops early (for example SentinelShare was briefly unavailable), the report keeps what was checked and says why; run `/bans scan` again to check everyone.
- Scanning needs Discord's **Server Members Intent** on the bot. If the bot replies "Scanning is turned off", ask a SentinelShare network admin.

## Commands

All replies are private to the person who ran the command.

| Command | What it does |
|---|---|
| `/bans check` | Look a player up by Arma UID, Discord member or ID, in-game name or IP. Shows active and past bans, which communities filed them, and other profiles that may be the same person. |
| `/bans add` | File a ban as yourself: Arma UID, name, type, reason, and optionally evidence links, Discord member or ID, and IP. Warns you if they already have a ban like it. |
| `/bans remove` | Lift a ban your community filed. Start typing the player's name or Arma UID and pick the ban; you're asked to confirm. |
| `/bans verify` | Peer verify (vouch for), send for staff review, staff verify, or deny a ban. |
| `/bans scan` | Check every member of this Discord for SentinelShare bans. Replies with how many have bans and a link to the full report on the website. Needs Ban Members or Manage Server. |
| `/staff add`, `/staff remove`, `/staff list` | Manage your community's access (server owners). |
| `/whoami` | Your SentinelShare account and what you can do. |
| `/recent` | The latest bans on the network. |
| `/stats` | Network totals. |
| `/setup alerts` | Pick the staff channel for join and member alerts (server owners). |
| `/setup notifications` | Pick the channel for new-ban posts (server owners). |
| `/invite`, `/ping` | Invite link; check the bot is online. |

## Troubleshooting

| Problem | Fix |
|---|---|
| Commands don't show up | Re-invite with the link above; it must include the `applications.commands` scope. |
| "Your Discord account isn't linked" | Your server owner runs `/staff add` for you. |
| "Your SentinelShare role doesn't allow that" | Moderators can't add staff; Read-Only can't file bans. Ask your server owner. |
| "You can only lift bans your community filed" | Ask the filing community, or a SentinelShare network admin. |
| No new-ban posts or alerts | Check the Discord tab on your community's page: the server ID must be your Discord's, and the bot must be able to post in those channels. |
| "Only your community's server owner or a network admin can change its Discord settings" | Ask your server owner to run `/setup`, or to set the channels on the website. |
| "You need Ban Members or Manage Server in this Discord to scan it" | Ask a Discord admin to run `/bans scan`, or to give you one of those permissions. |
| "Scanning is turned off" | The bot needs Discord's Server Members Intent. Ask a SentinelShare network admin. |
| "A scan of this Discord is already running" | Wait for it to finish; its report is under **Reports** on the website. |

Want the bot to do something specific to your community? See [Build your own Discord bot](/developers/build-a-discord-bot).

---

<!-- Source: https://sentinelshare.com/developers/build-a-discord-bot -->

# Build your own Discord bot

The [SentinelShare bot](/developers/discord-bot) covers checking, filing and lifting bans, and already [warns your staff](/developers/discord-bot#3-staff-alerts-and-new-ban-posts) when a banned player joins. If your community wants something of its own, such as adding these checks to a bot you already run, call the admin panel API from your bot.

Both examples below do two things:

1. **On member join:** look up the new member's Discord ID and post in your staff channel if they have an active ban anywhere on the network.
2. **`/check member`:** let staff check anyone on demand. Only members with Timeout Members permission see the command.

You need a community API key ([how to get one](/developers/admin-panel-api#get-a-key)). Keep it in an environment variable on the machine that runs the bot.

## Set up the Discord application

1. In the [Discord Developer Portal](https://discord.com/developers/applications), create an application and add a bot.
2. Under **Bot → Privileged Gateway Intents**, turn on **Server Members Intent**. Without it your bot never sees members join.
3. Invite it with the `bot` and `applications.commands` scopes and the **Send Messages** and **Embed Links** permissions.
4. Right-click your staff channel and your server and **Copy ID** (turn on Developer Mode in Discord's Advanced settings first).

Environment variables used below:

```bash
DISCORD_TOKEN=your-bot-token
SENTINEL_KEY=ssk_your_key_here
GUILD_ID=your-server-id
STAFF_CHANNEL_ID=your-staff-channel-id
```

## discord.js (Node.js 18+)

`npm install discord.js`, then `node bot.mjs`.

```js
// bot.mjs: SentinelShare checks for your Discord
import {
  Client, EmbedBuilder, Events, GatewayIntentBits, MessageFlags,
  PermissionFlagsBits, SlashCommandBuilder,
} from "discord.js";

const API = "https://sentinelshare.com/api/v1/integration";

async function lookup(params) {
  const url = new URL(API + "/lookup");
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SENTINEL_KEY}` } });
  if (!res.ok) throw new Error(`SentinelShare lookup failed: HTTP ${res.status}`);
  return res.json();
}

function banEmbed(user, result) {
  const active = result.bans.filter((b) => b.status === "active");
  return new EmbedBuilder()
    .setTitle(`${user.username}: ${active.length} active SentinelShare ban(s)`)
    .setURL(result.url)
    .setColor(0xeb6262)
    .addFields(
      active.slice(0, 5).map((b) => ({
        name: `${b.type} · ${b.verification.state.replaceAll("_", " ").toLowerCase()}`,
        value: `${b.filed_by.community}: ${b.reason ?? "No reason given"}`.slice(0, 1024),
      })),
    );
}

const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] });

client.once(Events.ClientReady, async (c) => {
  const check = new SlashCommandBuilder()
    .setName("check")
    .setDescription("Check a member on SentinelShare")
    .addUserOption((o) => o.setName("member").setDescription("Who to check").setRequired(true))
    .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers);
  await c.application.commands.create(check, process.env.GUILD_ID);
  console.log(`Ready as ${c.user.username}`);
});

// 1. Warn staff when a banned player joins
client.on(Events.GuildMemberAdd, async (member) => {
  try {
    const result = await lookup({ discord_id: member.id });
    if (!result.found || !result.bans.some((b) => b.status === "active")) return;
    const channel = await client.channels.fetch(process.env.STAFF_CHANNEL_ID);
    await channel.send({ content: `${member} just joined.`, embeds: [banEmbed(member.user, result)] });
  } catch (err) {
    console.error("Join check failed:", err);
  }
});

// 2. /check member
client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand() || interaction.commandName !== "check") return;
  await interaction.deferReply({ flags: MessageFlags.Ephemeral });
  const user = interaction.options.getUser("member", true);
  try {
    const result = await lookup({ discord_id: user.id });
    if (!result.found || !result.bans.some((b) => b.status === "active")) {
      await interaction.editReply(`${user.username} has no active bans on SentinelShare.`);
    } else {
      await interaction.editReply({ embeds: [banEmbed(user, result)] });
    }
  } catch {
    await interaction.editReply("SentinelShare didn't answer. Try again in a minute.");
  }
});

client.login(process.env.DISCORD_TOKEN);
```

## discord.py (Python 3.9+)

`pip install discord.py`, then `python bot.py`.

```python
# bot.py: SentinelShare checks for your Discord
import os

import aiohttp
import discord
from discord import app_commands

API = "https://sentinelshare.com/api/v1/integration"
GUILD = discord.Object(id=int(os.environ["GUILD_ID"]))
STAFF_CHANNEL_ID = int(os.environ["STAFF_CHANNEL_ID"])


async def lookup(**params) -> dict:
    headers = {"Authorization": f"Bearer {os.environ['SENTINEL_KEY']}"}
    async with aiohttp.ClientSession(headers=headers) as session:
        async with session.get(f"{API}/lookup", params=params, timeout=aiohttp.ClientTimeout(total=15)) as res:
            res.raise_for_status()
            return await res.json()


def active_bans(result: dict) -> list[dict]:
    return [b for b in result["bans"] if b["status"] == "active"] if result["found"] else []


def ban_embed(user: discord.abc.User, result: dict) -> discord.Embed:
    active = active_bans(result)
    embed = discord.Embed(
        title=f"{user.name}: {len(active)} active SentinelShare ban(s)",
        url=result["url"],
        color=0xEB6262,
    )
    for ban in active[:5]:
        state = ban["verification"]["state"].replace("_", " ").lower()
        embed.add_field(
            name=f"{ban['type']} · {state}",
            value=f"{ban['filed_by']['community']}: {ban['reason'] or 'No reason given'}"[:1024],
            inline=False,
        )
    return embed


class Bot(discord.Client):
    def __init__(self):
        intents = discord.Intents.default()
        intents.members = True  # needs Server Members Intent in the Developer Portal
        super().__init__(intents=intents)
        self.tree = app_commands.CommandTree(self)

    async def setup_hook(self):
        self.tree.copy_global_to(guild=GUILD)
        await self.tree.sync(guild=GUILD)


bot = Bot()


# 1. Warn staff when a banned player joins
@bot.event
async def on_member_join(member: discord.Member):
    try:
        result = await lookup(discord_id=str(member.id))
    except aiohttp.ClientError as exc:
        print("Join check failed:", exc)
        return
    if active_bans(result):
        channel = bot.get_channel(STAFF_CHANNEL_ID)
        await channel.send(f"{member.mention} just joined.", embed=ban_embed(member, result))


# 2. /check member
@bot.tree.command(name="check", description="Check a member on SentinelShare")
@app_commands.default_permissions(moderate_members=True)
async def check(interaction: discord.Interaction, member: discord.User):
    await interaction.response.defer(ephemeral=True)
    try:
        result = await lookup(discord_id=str(member.id))
    except aiohttp.ClientError:
        await interaction.followup.send("SentinelShare didn't answer. Try again in a minute.", ephemeral=True)
        return
    if active_bans(result):
        await interaction.followup.send(embed=ban_embed(member, result), ephemeral=True)
    else:
        await interaction.followup.send(f"{member.name} has no active bans on SentinelShare.", ephemeral=True)


bot.run(os.environ["DISCORD_TOKEN"])
```

## Ideas to take further

- Check by Arma UID too: if your whitelist or verification bot knows a member's Arma UID, pass `arma_uid` as well as `discord_id`.
- Only alert on verified bans: check `ban.verification.state` is `PEER_VERIFIED` or `STAFF_VERIFIED`.
- Post every new network ban to a channel: poll `GET /bans` with a saved cursor every few minutes (see [Node.js and Python](/developers/node-and-python)) and send an embed per new record. The official bot already does this with `/setup notifications`.
- Remember each key allows 300 requests a minute. A busy server's join wave fits easily; don't look up every member of a large server at once.
- To check everyone already in your server, use the official bot's `/bans scan`: it checks 1,000 members per request and saves a report on the website. See [Scan your Discord](/developers/discord-bot#4-scan-your-discord-for-banned-members).

---

<!-- Source: https://sentinelshare.com/developers/changelog -->

# Changelog

Changes that affect developers using the admin panel API or the SentinelShare bot. Fields are only ever added to the API, never renamed or removed.

## 26 October 2026

- **Staff alerts.** The SentinelShare bot warns a staff channel when someone with an active ban joins your Discord, and when another community bans someone already in it. Set the channels on your community's page or with `/setup alerts` and `/setup notifications`. See [Staff alerts](/developers/discord-bot#3-staff-alerts-and-new-ban-posts).
- **Community pages.** Every community has a page with its staff, activity, bans filed and track record, plus its Discord settings.
- **Appeal tracking.** Appellants get a private link to follow their appeal, with a message from the community that decides it. Appeals are decided by the community that filed the ban, or by network staff.
- **Discord scans and Reports.** `/bans scan` checks every member of a Discord for SentinelShare bans, 1,000 at a time. It replies with how many have bans and a link to the report, which is saved under **Reports** on the website with every matched member, their player profile, ban types and the communities that filed them. See [Scan your Discord](/developers/discord-bot#4-scan-your-discord-for-banned-members).
- **Developer docs.** These pages, each downloadable as Markdown, plus [llms.txt](/llms.txt) and the [whole set as one file](/llms-full.txt) for AI assistants.
- **SentinelShare bot.** `/bans check`, `/bans add`, `/bans remove`, `/bans verify`, `/staff add`, `/staff remove`, `/staff list`, `/whoami` and `/setup notifications`. Commands run as the staff member who typed them. See [Add the SentinelShare bot](/developers/discord-bot).
- **Lifting bans.** Staff who can file bans can now lift bans filed by anyone in their own community, on the website, in Discord and through the API.
- **Admin panel API launched.** Community API keys, `GET /integration/bans` (change feed) and `GET /integration/lookup`. See [Admin panel API](/developers/admin-panel-api).
- **One profile per player.** Arma UIDs, Discord IDs, IP addresses and names now live on a single player profile, so a ban record's `player` lists every known identifier, and profiles that may be the same person appear in `other_matches`.
