Sending Heartbeats
Keep your game servers on the public list: the heartbeat endpoint, payload fields, defaults, limits, and clean delisting.
Heartbeats are how servers hosted outside PingCore stay listed. Your game server (or a sidecar next to it) sends one HTTP request every 30 seconds; Discovery drops the server 90 seconds after its last heartbeat, so three missed beats delist it automatically. There is no registration step and no cleanup. Servers hosted on PingCore skip all of this: they connect automatically through a fleet.
You need a token with the Heartbeat (or Both) scope. Issue one on your Discovery app page. The examples use https://discovery.pingcore.io as the base URL; your app page shows the exact URL for your environment.
The heartbeat request
POST /v1/heartbeat
Authorization: Bearer dsc_...
Content-Type: application/json{
"serverId": "203.0.113.10:27015",
"name": "EU West #1",
"ip": "203.0.113.10",
"port": 27015,
"queryPort": 27016,
"players": 4,
"maxPlayers": 32,
"version": "1.4.2",
"meta": { "map": "coastline", "seed": "8134429" }
}Fields
name (required): display name, 1 to 100 characters.
port (required): the port players connect to, 1 to 65535.
players (required): current player count, 0 to 1,000,000.
maxPlayers (required): capacity, 0 to 1,000,000.
ip (optional): defaults to the source address Discovery observes on the request. Send it explicitly only when the observed address is wrong (a proxied or NAT'd setup where the machine's outbound IP differs from the address players connect to).
serverId (optional): your stable identifier for this server, up to 128 characters from
A-Z a-z 0-9 . _ : [ ] -. Defaults toip:port(IPv6 addresses are bracketed, e.g.[2001:db8::1]:27015). Two heartbeats with the sameserverIdupdate one entry; different ids create separate entries.queryPort (optional): a separate query port, 1 to 65535. Also used as the probe target for
udp-echoverification when present.version (optional): a build or protocol version string, up to 50 characters. Clients can filter the list by it.
meta (optional): a free-form JSON object of your own fields (map, mode, region, anything). The JSON-encoded size is capped at 2048 bytes. Clients can filter on individual keys.
The smallest valid payload is { "name", "port", "players", "maxPlayers" }.
The advertised IP must be public
Discovery rejects heartbeats that advertise a private, loopback, or otherwise non-routable IP with a 400, because players could never connect to it. If you test on a LAN, either give the machine a public address or test against your own local deployment.
The response
{
"error": false,
"serverId": "203.0.113.10:27015",
"ip": "203.0.113.10",
"expiresIn": 90,
"verificationMode": "udp-echo",
"verified": "pending",
"lastProbeError": null
}expiresIn: seconds until this entry expires without another heartbeat.
verificationMode: the mode Discovery actually enforces. This is the app's configured mode, except on open-registration apps where it is always
udp-echo. When it isnone, bothverifiedandlastProbeErrorarenull(nothing probed the server, so reporting a failure state would be misleading).verified:
pending,verified, orunverified. While notverified(and the mode is notnone), the server accepts heartbeats but stays hidden from the public list.lastProbeError: why the last probe failed, for self-diagnosis. See Reachability Verification.
Error responses
400: invalid payload. The message names the exact field and rule.
409: a new
serverIdwas refused by a limit. The body carries a machine-readablereasonandlimitso a fleet manager can back off without parsing the message.401 / 403 / 503: see Discovery Overview for the token and service state semantics.
The two 409 shapes
{
"error": true,
"message": "This Discovery app already has 500 live servers, which is its limit. ...",
"reason": "cap",
"limit": 500
}{
"error": true,
"message": "This source IP already has 10 live servers registered on this app, which is its open-registration per-IP limit. ...",
"reason": "ip_cap",
"limit": 10
}reason: "cap": the app is at its Concurrent Server Limit. Contact support to raise it.reason: "ip_cap": on an open-registration app, this source IP already has its allowed number of live servers. The default is 10; raise it per app with Max servers per source IP on the app page (see Discovery Overview for registration modes).
Both limits refuse only brand-new serverIds. Servers already live keep reporting normally and keep refreshing their TTL, so hitting a limit never delists a running server.
The per-IP cap counts the source address Discovery observes on the request, never the ip field in the payload (which the sender controls). The default of 10 tolerates a few servers behind one NAT; raise it if your player base commonly shares addresses (LAN parties, offices, campus networks).
Heartbeats are rate limited per app (not per IP): 1,000 requests per minute by default, shared across every server using the app's tokens. At the 30 second cadence, each server costs 2 requests per minute, so 500 servers sit exactly at the cap with no headroom. Treat around 400 servers as the comfortable ceiling and contact support to raise the limit before you grow past it.
Client-hosted (open) apps
On an app with open registration (see Discovery Overview), the game binary itself sends the heartbeats, so the token compiled into it is public by design. That is expected and not a vulnerability in this mode: the token only routes heartbeats to your app, and the list is protected by mandatory endpoint verification plus the per-IP limit above. Two consequences for your integration:
Every server must answer the UDP echo challenge, or it never reaches the public list. Ship the responder inside the game; see Reachability Verification.
Handle the
ip_cap409 gracefully in the client (for example, tell the host their network already has the maximum number of listed servers). Existing servers on the same IP are unaffected.
Examples
A note on freshly issued tokens: a heartbeat sent within the first minute after issuing a token can get a 401, because the token has not reached the Discovery service yet (see the revocation window in Discovery Overview). Keep heartbeating; it resolves itself.
The quickest way to see your app working. Run it once, and the server appears on your public list URL within seconds, then drops off 90 seconds after the last run:
curl -X POST https://discovery.pingcore.io/v1/heartbeat \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "EU West #1", "port": 27015, "players": 4, "maxPlayers": 32 }'// Call every 30 seconds while the server is up.
const HEARTBEAT_URL = 'https://discovery.pingcore.io/v1/heartbeat';
const TOKEN = process.env.DISCOVERY_TOKEN;
async function heartbeat(players, maxPlayers) {
const response = await fetch(HEARTBEAT_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'EU West #1',
port: 27015,
players,
maxPlayers,
version: '1.4.2',
meta: { map: 'coastline', seed: '8134429' },
}),
});
// The response reports "verified" when verification is enabled on the app.
console.log('[discovery]', response.status, await response.json());
}
setInterval(() => heartbeat(currentPlayers(), 32), 30 * 1000);// Call every 30s while the server is up.
// Discovery drops the server 90s after the last heartbeat.
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static readonly HttpClient Http = new HttpClient();
async Task HeartbeatAsync(int players, int maxPlayers, CancellationToken ct)
{
var payload = new
{
name = "EU West #1",
port = 27015,
players,
maxPlayers,
version = "1.4.2",
meta = new { map = "coastline", seed = "8134429" }
};
using var request = new HttpRequestMessage(HttpMethod.Post, "https://discovery.pingcore.io/v1/heartbeat");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_TOKEN");
request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
using var response = await Http.SendAsync(request, ct);
// 200 means listed. The body reports "verified" when verification is enabled.
var body = await response.Content.ReadAsStringAsync(ct);
Console.WriteLine($"[discovery] {(int)response.StatusCode} {body}");
}// Call every 30s while the server is up (libcurl).
#include <curl/curl.h>
#include <string>
void Heartbeat(int players, int maxPlayers)
{
const std::string body =
"{\"name\":\"EU West #1\",\"port\":27015,"
"\"players\":" + std::to_string(players) + ","
"\"maxPlayers\":" + std::to_string(maxPlayers) + ","
"\"version\":\"1.4.2\","
"\"meta\":{\"map\":\"coastline\",\"seed\":\"8134429\"}}";
CURL* curl = curl_easy_init();
if (!curl) return;
curl_slist* headers = nullptr;
headers = curl_slist_append(headers, "Content-Type: application/json");
headers = curl_slist_append(headers, "Authorization: Bearer YOUR_TOKEN");
curl_easy_setopt(curl, CURLOPT_URL, "https://discovery.pingcore.io/v1/heartbeat");
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 5L);
curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
}Delist on clean shutdown
Call the delete endpoint once when a server shuts down cleanly, so players stop seeing a dead server immediately instead of waiting out the 90 second TTL. URL-encode the serverId; the default form contains a colon (: becomes %3A).
curl -X DELETE "https://discovery.pingcore.io/v1/servers/203.0.113.10%3A27015" \
-H "Authorization: Bearer YOUR_TOKEN"process.on('SIGTERM', async () => {
await fetch(
`https://discovery.pingcore.io/v1/servers/${encodeURIComponent(`${publicIp}:27015`)}`,
{ method: 'DELETE', headers: { 'Authorization': `Bearer ${TOKEN}` } }
);
process.exit(0);
});// Call once on clean shutdown.
async Task DelistAsync(string serverId)
{
var url = $"https://discovery.pingcore.io/v1/servers/{Uri.EscapeDataString(serverId)}";
using var request = new HttpRequestMessage(HttpMethod.Delete, url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "YOUR_TOKEN");
await Http.SendAsync(request);
}// Call once on clean shutdown (libcurl).
void Delist(const std::string& serverId)
{
CURL* curl = curl_easy_init();
if (!curl) return;
char* escaped = curl_easy_escape(curl, serverId.c_str(), 0);
const std::string url = std::string("https://discovery.pingcore.io/v1/servers/") + escaped;
curl_slist* headers = curl_slist_append(nullptr, "Authorization: Bearer YOUR_TOKEN");
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE");
curl_easy_perform(curl);
curl_free(escaped);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
}The response reports removed: true when an entry was deleted and removed: false when it had already expired. Both are normal. A server that crashed without delisting expires on the TTL instead.