API голосов

Плагин сервера узнаёт о голосах игроков и выдаёт награду. Ключ и вебхук бесплатны для подтверждённых владельцев: их выдаёт кабинет сервера.

Ключ

Создайте ключ в кабинете сервера, блок «Награды за голоса». Ключ получает права votes:read и votes:write и работает с одним сервером, пока владение подтверждено.

Authorization: Bearer sp_live_ВАШ_КЛЮЧ

Ключ показывается один раз. Храните его на игровом сервере и не отправляйте игровому клиенту.

Список голосов

GET /api/integration/v1/votes?status=provisional&claimed=false&limit=100

{
  "votes": [{
    "voteId": "uuid",
    "serverId": "uuid",
    "inGameName": "76561198000000000",
    "votedAt": "2026-10-23T18:04:11.000Z",
    "voteDay": "2026-10-23",
    "status": "provisional",
    "claimedAt": null
  }],
  "nextAfterId": null
}
status
provisional принят и ждёт проверки, counted засчитан через 48 часов, all ещё и аннулированные
claimed
false без отметки о награде, true с отметкой
after_id
продолжение списка с nextAfterId
limit
до 100

inGameName приходит, только если игрок указал ник или SteamID64 и согласился передать его владельцу. Данных аккаунта SpawnPulse в ответе нет.

Отметка о награде

POST /api/integration/v1/votes/{voteId}/claim
Idempotency-Key: {voteId}

200 {"voteId":"…","claimed":true,"claimedAt":"…","replay":false}
409 {"error":"ALREADY_CLAIMED","claimedAt":"…"}

Повтор с тем же Idempotency-Key вернёт тот же ответ 200 с "replay": true, поэтому запрос можно повторить после обрыва связи. Голос на проверке и аннулированный голос отметить нельзя: ответ 404.

# Новые голоса, ещё без награды
curl -H "Authorization: Bearer sp_live_ВАШ_КЛЮЧ" \
  "https://spawnpulse.com/api/integration/v1/votes?status=provisional&claimed=false&limit=100"

# Отметить голос как награждённый (повтор с тем же ключом идемпотентности безопасен)
curl -X POST -H "Authorization: Bearer sp_live_ВАШ_КЛЮЧ" \
  -H "Idempotency-Key: ID_ГОЛОСА" \
  "https://spawnpulse.com/api/integration/v1/votes/ID_ГОЛОСА/claim"

Вебхук

Укажите в кабинете адрес HTTPS. SpawnPulse отправит POST на каждое событие голоса:

X-SpawnPulse-Event
vote.provisional, vote.counted или vote.annulled
X-SpawnPulse-Delivery
UUID доставки, один и тот же при повторах
X-SpawnPulse-Signature
t=<unix>,v1=<hex>, где v1 = HMAC-SHA256(секрет, t + "." + тело)
{
  "voteId": "3f0c6c2e-8a4b-4f7e-9d1a-2b6f0e4c9a17",
  "serverId": "1b4e28ba-2fa1-11d2-883f-0016d3cca427",
  "inGameName": "76561198000000000",
  "votedAt": "2026-10-23T18:04:11.000Z",
  "voteDay": "2026-10-23",
  "status": "provisional"
}

Ответьте кодом 2xx за 5 секунд. Без него SpawnPulse повторит доставку через 1 мин, 5 мин, 30 мин, 2 ч, 6 ч и 12 ч. Перенаправления не выполняются, адреса локальных и внутренних сетей не принимаются. Данных о сериях голосов в вебхуке нет.

Проверка подписи (Node.js)

const crypto = require("node:crypto");

// rawBody: the request body exactly as received, before JSON.parse.
function verifySpawnPulse(secret, rawBody, header, now = Math.floor(Date.now() / 1000)) {
  if (!header) return false;
  let t = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const [key, value] = part.split("=", 2).map((item) => item.trim());
    if (key === "t") t = Number(value);
    if (key === "v1") signatures.push(value);
  }
  if (!Number.isInteger(t) || Math.abs(now - t) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(t + "." + rawBody, "utf8").digest();
  return signatures.some((value) => {
    const actual = Buffer.from(value, "hex");
    return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
  });
}

EasyVote и другие плагины

Эндпоинт /api/vote/v1 отвечает в формате Listforge, поэтому EasyVote и похожие плагины для Rust, ARK и 7 Days to Die работают без доработки. Ключ можно передать параметром key, но заголовок Authorization надёжнее.

# 0: голоса за 24 часа нет; 1: голос есть, награда не выдана; 2: награда выдана
GET https://spawnpulse.com/api/vote/v1?object=votes&element=claim&key=sp_live_ВАШ_КЛЮЧ&steamid=76561198000000000

# Отметить награду выданной: 1, если отметили сейчас; 0, если отмечать нечего
GET https://spawnpulse.com/api/vote/v1?action=post&object=votes&element=claim&key=sp_live_ВАШ_КЛЮЧ&steamid=76561198000000000

# Ники и голоса за месяц: [{"nickname":"…","votes":12}]
GET https://spawnpulse.com/api/vote/v1?object=servers&element=voters&key=sp_live_ВАШ_КЛЮЧ&month=current&format=json

EasyVote: свой сайт

"VoteSitesAPI": {
  "SpawnPulse": {
    "API Claim Reward (GET URL)": "https://spawnpulse.com/api/vote/v1?action=post&object=votes&element=claim&key={0}&steamid={1}",
    "API Vote status (GET URL)": "https://spawnpulse.com/api/vote/v1?object=votes&element=claim&key={0}&steamid={1}",
    "Vote link (URL)": "https://spawnpulse.com/games/{0}#vote",
    "Site Uses Username Instead of Player Steam ID?": false
  }
},
"Servers": {
  "MyServer": {
    "SpawnPulse": "rust/my-server:sp_live_ВАШ_КЛЮЧ"
  }
}

Вместо rust/my-server подставьте часть адреса страницы сервера после /games/.

Oxide и Carbon: свой плагин

using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Core.Libraries;

namespace Oxide.Plugins
{
    [Info("SpawnPulseVotes", "SpawnPulse", "1.0.0")]
    [Description("Награда за голос на SpawnPulse")]
    public class SpawnPulseVotes : RustPlugin
    {
        private const string ApiKey = "sp_live_ВАШ_КЛЮЧ";
        private const string Api = "https://spawnpulse.com/api/integration/v1/votes";

        private class Vote { public string voteId; public string inGameName; }
        private class VoteList { public List<Vote> votes; }

        private Dictionary<string, string> Headers() =>
            new Dictionary<string, string> { ["Authorization"] = "Bearer " + ApiKey };

        private void OnServerInitialized() => timer.Every(60f, Poll);

        private void Poll()
        {
            webrequest.Enqueue(Api + "?status=provisional&claimed=false&limit=100", null, (code, body) =>
            {
                if (code != 200 || string.IsNullOrEmpty(body)) return;
                foreach (var vote in JsonConvert.DeserializeObject<VoteList>(body).votes)
                {
                    var player = vote.inGameName == null ? null : BasePlayer.Find(vote.inGameName);
                    if (player == null) continue; // игрок не в сети: голос подождёт следующего опроса
                    var headers = Headers();
                    headers["Idempotency-Key"] = vote.voteId;
                    webrequest.Enqueue(Api + "/" + vote.voteId + "/claim", "", (claimCode, _) =>
                    {
                        if (claimCode == 200) GiveReward(player);
                    }, this, RequestMethod.POST, headers);
                }
            }, this, RequestMethod.GET, Headers());
        }

        private void GiveReward(BasePlayer player)
        {
            player.inventory.GiveItem(ItemManager.CreateByName("scrap", 25));
            player.ChatMessage("Спасибо за голос на SpawnPulse");
        }
    }
}