Vote API
How a game finds out that a player voted for it. There are two ways, and you can use both: GrindIndex calls your server for every vote (webhook), or your server asks for new votes (claim API).
Vote link
https://grindindex.com/vote/<your-slug>?u=<in-game name>&ref=<optional value>u | The player's in-game name, 1 to 24 letters, digits, spaces, _ or -. Pre-fills the vote form; the player can change it. |
ref | Anything you want back, up to 128 characters, for example a signed token of your own. Returned unchanged. Never trust it on its own: anyone can build a link. |
A player can vote for the same game once every 12 hours, checked by IP address and by in-game name.
Webhook
Set a webhook URL on your game's settings page. Every vote is sent as:
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-GrindIndex-Event: vote.accepted
X-GrindIndex-Event-Id: 3f0c2b6e-8a1d-4c55-9a52-0d1f3c9e7b21
X-GrindIndex-Timestamp: 1793975400
X-GrindIndex-Signature: v1=5d41402abc4b2a76b9719d911017c592...
{
"type": "vote.accepted",
"event_id": "3f0c2b6e-8a1d-4c55-9a52-0d1f3c9e7b21",
"game": "your-slug",
"username": "Zezima",
"ref": null,
"voted_at": "2026-11-05T15:10:00.000Z",
"next_vote_at": "2026-11-06T03:10:00.000Z",
"counted": true,
"ranked": true
}event_id | Unique per vote. Store it and ignore repeats: a retry carries the same id. |
counted | The vote is genuine and may be rewarded. |
ranked | The vote also counts toward your rank. A vote can be counted but not ranked. |
next_vote_at | When this player can vote again, for a countdown in your game. |
Answer with any 2xx status within 10 seconds. Anything else is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, then we stop. The settings page lists every attempt and the response. The test event from the settings page has the same shape and should not be rewarded.
When we find votes to be fake we revoke them and send a vote.revoked event with the same event_id as the original vote, and counted and ranked set to false. Take the reward back if your game supports that, or ignore it.
Signature
The signature is v1= followed by the hex HMAC-SHA256 of timestamp + "." + raw body, keyed with your signing secret. Compare it in constant time, and reject timestamps more than 5 minutes off. Use the raw request body, not re-encoded JSON.
Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
static boolean verify(String secret, String timestamp, String rawBody, String signature) throws Exception {
long ts = Long.parseLong(timestamp);
if (Math.abs(System.currentTimeMillis() / 1000 - ts) > 300) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] digest = mac.doFinal((timestamp + "." + rawBody).getBytes(StandardCharsets.UTF_8));
String expected = "v1=" + HexFormat.of().formatHex(digest);
return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8));
}PHP
$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_GRINDINDEX_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_GRINDINDEX_SIGNATURE'] ?? '';
$expected = 'v1=' . hash_hmac('sha256', $ts . '.' . $body, getenv('GRINDINDEX_SECRET'));
if (abs(time() - (int) $ts) > 300 || !hash_equals($expected, $sig)) {
http_response_code(401);
exit;
}
$vote = json_decode($body, true);
// Insert $vote['event_id'] with a UNIQUE constraint; reward only if the insert succeeded.
http_response_code(204);Node
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(secret, timestamp, rawBody, signature) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
const expected = 'v1=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex')
const a = Buffer.from(expected), b = Buffer.from(signature ?? '')
return a.length === b.length && timingSafeEqual(a, b)
}Claim API
For servers that cannot receive requests. Create an API key on the settings page and send it as a bearer token. A vote can be claimed once, so if you use the webhook too, claim each vote before rewarding it.
GET https://grindindex.com/api/v1/votes/unclaimed?limit=50
POST https://grindindex.com/api/v1/votes/<event_id>/claim
GET https://grindindex.com/api/v1/votes/check?username=Zezima
Authorization: Bearer gi_...unclaimed | Counted votes from the last 7 days not yet claimed, oldest first. |
claim | 200 the first time, 409 if it was already claimed, 404 if unknown. Reward only on 200. |
check | Whether a name (or ref) voted within the cooldown, with next_vote_at. |
Server status
On the settings page you can give your game server's address and port. Every 30 minutes we check that the port accepts a connection, and show the result and the 30-day uptime on your page. To show players online, add a status URL that answers a GET request with the number, as plain text or as JSON:
120
{"players": 120}The keys players, online, players_online, playerCount and count are read. Keep the response under 16 KB and answer within 6 seconds.
Rank badge
A badge with your current rank, updated every 10 minutes. The code is on your settings page.
<a href="https://grindindex.com/g/<slug>"><img src="https://grindindex.com/badge/<slug>.svg" alt="On GrindIndex" height="22"></a>Public data
Rankings and vote counts are open. No key, CORS allowed, cached for 5 minutes.
GET https://grindindex.com/api/v1/games?category=scapelike
GET https://grindindex.com/api/v1/games/<slug>New games and updates are also published as Atom feeds:
GET https://grindindex.com/feed.xml
GET https://grindindex.com/g/<slug>/feed.xml