Developers
Logbook API
Read and write your PingDX logbook from another logging program, a script or your website.
On this page
Introduction
The PingDX API lets a member keep their PingDX logbook in sync with another logging program, a script or their own website: download QSOs, add new ones, correct or delete them. It only gives access to the logbook of the member who created the key.
Base URL
https://pingdx.org/api/v1Requests and responses are JSON (UTF-8). Send Content-Type: application/json with every request that has a body. Dates are ISO 8601 strings; the server always answers in UTC (2026-10-05T18:42:00.000Z).
A machine-readable description (OpenAPI 3.1, public, no key needed) is available for code generators and tools such as Postman: https://pingdx.org/api/v1/openapi.json
Getting a key
Every member creates their own keys in Profile → API access. Give each key a name (the program or site that uses it) and choose its rights:
- Read only (
READ): can read the logbook (GETrequests) — enough to display your QSOs on a website. - Read and write (
WRITE): can also create, update and delete QSOs — needed for a two-way sync with a logging program. - The full key is shown only once, right after creation: copy it straight away. PingDX only keeps a fingerprint of it and can't show it again.
- You can revoke a key at any time: it stops working immediately. At most 10 active keys per member.
Security
- Treat a key like a password. Never put a WRITE key in the public code of a website (JavaScript sent to visitors' browsers): anyone could read it and modify your logbook. Call the API from your server, or use a READ key.
- A key only opens the logbook API: never your messages, profile, password or the rest of your account.
- Use one key per program, so you can revoke one without breaking the others. Keys start with
pdx_…: easy to spot if one is published by mistake — revoke it then.
Authentication
Send the key with every request, in the Authorization header (Bearer) or in the X-API-Key header. Your PingDX login session is not accepted by this API, and cookies are never used.
Authorization: Bearer pdx_…
# or
X-API-Key: pdx_…Without a valid key the API answers 401; a READ key trying to write gets 403:
401 API_KEYS.INVALIDMissing, unknown or revoked key (or account no longer active).403 API_KEYS.READ_ONLYThe key is read-only: create a WRITE key to write.
Endpoints
All paths are relative to the base URL. Each QSO is identified by its clientId, a UUID chosen by your program.
GET/me
Rights needed: READ or WRITE key
The member the key belongs to (callsign, locator) and the key's rights. Handy to check a key.
Response
{
"callsign": "14KM001",
"locator": "JN03ql",
"scope": "WRITE"
}Errors
401 API_KEYS.INVALIDMissing, unknown or revoked key (or account no longer active).429 GENERIC.RATE_LIMITEDToo many requests: wait a little and try again.
GET/logs
Rights needed: READ or WRITE key
The logbook, oldest change first (ordered by last server-side write), page by page.
Without updatedSince you get the whole logbook. With it, only the QSOs created, modified or cross-confirmed after that instant.
Parameters
updatedSincestring (ISO 8601)queryoptionalISO 8601 date-time with time zone (e.g.
2026-10-01T00:00:00Z). Pass theserverTimeof your previous sync.limitinteger 1–500 (200)queryoptionalQSOs per page, from 1 to 500. Default: 200.
cursorstringqueryoptionalThe
nextCursorof the previous page, to get the next one. Keep the sameupdatedSincewhile paging.
Response
items: the QSOs of the page (see the QSO object). nextCursor: pass it as cursor to get the next page; null on the last page. serverTime: the server time when the request started — your next updatedSince.
{
"items": [
{
"id": "cmgd4w1x70003s60e8k2v9qhz",
"clientId": "0b9e6c1e-5f3a-4d2b-9c7e-1a2b3c4d5e6f",
"band": "ELEVEN_METERS",
"frequencyMhz": "27.555",
"channel": null,
"ctcssTone": null,
"dcsCode": null,
"mode": "USB",
"callsignWorked": "14KM123",
"operatorCallsign": "14KM001",
"dxLocator": "JN18du",
"dxLat": null,
"dxLng": null,
"txPowerW": 4,
"myLocator": "JN03ql",
"splitKhz": null,
"path": null,
"qsoStatus": null,
"rstSent": "59",
"rstReceived": "57",
"qslSent": false,
"qslReceived": false,
"qslVia": null,
"qsoAt": "2026-10-05T18:42:00.000Z",
"notes": null,
"radio": "President Lincoln II+",
"antenna": "Sirio 827",
"crossConfirmedAt": null,
"spottedAt": null,
"createdAt": "2026-10-05T18:43:10.512Z",
"syncedAt": "2026-10-05T18:43:10.512Z"
}
],
"nextCursor": null,
"serverTime": "2026-10-05T18:45:12.345Z"
}Errors
400 VALIDATION.FAILEDInvalid request: a bad parameter, a clientId that isn't a UUID, or a batch without valid entries array (1 to 200). details describes the problem.401 API_KEYS.INVALIDMissing, unknown or revoked key (or account no longer active).429 GENERIC.RATE_LIMITEDToo many requests: wait a little and try again.
GET/logs/ids
Rights needed: READ or WRITE key
The ids of every QSO in the logbook, without paging. Compare them with your copy to find the QSOs deleted on PingDX (or from another program).
Response
clientId can be null for a few very old QSOs created before synchronisation existed.
{
"items": [
{ "id": "cmgd4w1x70003s60e8k2v9qhz", "clientId": "0b9e6c1e-5f3a-4d2b-9c7e-1a2b3c4d5e6f" },
{ "id": "cmgd51b2c0007s60efq3m1abc", "clientId": "7d1f2a90-3b4c-4e5f-8a6b-9c0d1e2f3a4b" }
]
}Errors
401 API_KEYS.INVALIDMissing, unknown or revoked key (or account no longer active).429 GENERIC.RATE_LIMITEDToo many requests: wait a little and try again.
POST/logs/batch
Rights needed: WRITE key
Creates or updates up to 200 QSOs in one request. A QSO whose clientId is unknown is created; an existing clientId updates that QSO.
Each QSO is validated on its own: an invalid QSO is reported in its result and never prevents the others from being saved. The request only fails as a whole (400) when entries is missing, empty or longer than 200.
Parameters
entriesQSO[] (1–200)bodyrequiredThe QSOs to create or update (see the QSO object).
Request body
{
"entries": [
{
"clientId": "0b9e6c1e-5f3a-4d2b-9c7e-1a2b3c4d5e6f",
"band": "ELEVEN_METERS",
"frequencyMhz": "27.555",
"mode": "USB",
"callsignWorked": "14KM123",
"qsoAt": "2026-10-05T18:42:00Z",
"rstSent": "59",
"rstReceived": "57",
"dxLocator": "JN18DU"
},
{
"clientId": "7d1f2a90-3b4c-4e5f-8a6b-9c0d1e2f3a4b",
"band": "PMR446",
"channel": 8,
"ctcssTone": "67.0",
"mode": "FM",
"callsignWorked": "PMR-ALPHA",
"qsoAt": "2026-10-05T19:05:00+02:00",
"dxLocator": "JN1"
}
]
}Response
One result per QSO. id is the server id, or null if the QSO was rejected; error then gives the reason. permanent: true means the QSO itself is invalid: it will be rejected again until it is corrected — don't retry it as is. A rejection without permanent is temporary: try again later.
{
"results": [
{ "clientId": "0b9e6c1e-5f3a-4d2b-9c7e-1a2b3c4d5e6f", "id": "cmgd4w1x70003s60e8k2v9qhz", "spottedAt": null },
{ "clientId": "7d1f2a90-3b4c-4e5f-8a6b-9c0d1e2f3a4b", "id": null, "error": "LOCATOR_INVALID", "permanent": true }
]
}Errors
400 VALIDATION.FAILEDInvalid request: a bad parameter, a clientId that isn't a UUID, or a batch without valid entries array (1 to 200). details describes the problem.401 API_KEYS.INVALIDMissing, unknown or revoked key (or account no longer active).403 API_KEYS.READ_ONLYThe key is read-only: create a WRITE key to write.429 GENERIC.RATE_LIMITEDToo many requests: wait a little and try again.
DELETE/logs/{clientId}
Rights needed: WRITE key
Deletes a QSO of your logbook by its clientId. If it had been sent to the DX cluster, the spot is removed too.
Parameters
clientIdstring (UUID)pathrequiredThe
clientIdof the QSO (a UUID).
Response
No body (204). Send this request without a body and without Content-Type.
HTTP/1.1 204 No ContentErrors
400 VALIDATION.FAILEDInvalid request: a bad parameter, a clientId that isn't a UUID, or a batch without valid entries array (1 to 200). details describes the problem.401 API_KEYS.INVALIDMissing, unknown or revoked key (or account no longer active).403 API_KEYS.READ_ONLYThe key is read-only: create a WRITE key to write.404 GENERIC.NOT_FOUNDNo QSO with this clientId in your logbook.429 GENERIC.RATE_LIMITEDToo many requests: wait a little and try again.
GET/openapi.json
Rights needed: none (public)
The OpenAPI 3.1 description of this API (JSON). Public: no key needed.
The QSO object
The same object is sent to POST /logs/batch and returned by GET /logs. The band field selects one of two variants:
- 11 m (CB) —
"band": "ELEVEN_METERS":frequencyMhz(optional);channel,ctcssToneanddcsCodemust be absent ornull. - PMR446 —
"band": "PMR446":channel(1 to 16) is required,ctcssTone/dcsCodeoptional;frequencyMhzmust be absent ornull. - An update replaces the QSO: always send the complete QSO. An omitted field is reset (empty, or
falseforqslSent/qslReceived), exceptoperatorCallsign,dxLat,dxLng,txPowerW,radioandantenna, which keep their stored value when omitted. - Text is trimmed, locators are normalised (
jn18DU→JN18du) and unknown fields are ignored. Read-only fields are set by the server: there's no need to send them.
clientIdrequiredstring (UUID)Your stable id for this QSO: a random UUID (e.g.
crypto.randomUUID()), generated once and stored with the QSO in your program. It is the sync key.bandrequired"ELEVEN_METERS" | "PMR446"The band:
ELEVEN_METERS(11 m / CB) orPMR446.qsoAtrequiredstring (ISO 8601)Date and time of the QSO, ISO 8601 with time zone (
Zor+02:00). Stored and returned in UTC.frequencyMhzoptionalstring "27.555" · 26.000–28.00011 m only: the frequency in MHz, as a string with exactly 3 decimals, between 26.000 and 28.000.
channelrequired on PMR446integer 1–16PMR446 only (required there): the channel, 1 to 16.
ctcssToneoptionalstring "67.0" · ^\d{2,3}\.\d$PMR446 only: CTCSS tone in Hz, one decimal (
67.0,103.5).dcsCodeoptionalstring "D023N" · ^D?[0-7]{3}[NI]?$PMR446 only: DCS code, 3 octal digits with optional
Dprefix andN/Ipolarity (D023N).modeoptional"FM" | "AM" | "SSB" | "USB" | "LSB" | "CW" | "DIGITAL"The mode.
callsignWorkedoptionalstring ≤ 20The callsign of the station worked.
operatorCallsignoptionalstring ≤ 20Your own callsign for this QSO (useful if you use several). Kept when omitted on an update.
dxLocatoroptionalstring "JN18" | "JN18du"Maidenhead locator of the station worked, 4 or 6 characters.
dxLatoptionalnumber −90…90Exact latitude of the station worked, if known. Maps prefer it over the locator.
dxLngoptionalnumber −180…180Exact longitude of the station worked, if known.
txPowerWoptionalinteger 0–100000Your transmit power in whole watts.
myLocatoroptionalstring "JN03" | "JN03ql"Your locator for this QSO (e.g. when portable), 4 or 6 characters.
splitKhzoptionalinteger −99999…99999Split in kHz (difference between transmit and receive frequencies).
pathoptional"SP" | "LP"Propagation path: short path (
SP) or long path (LP).qsoStatusoptional"HRD" | "WKD" | "CFM"Contact status: heard (
HRD), worked (WKD), confirmed (CFM).rstSentoptionalstring ≤ 10Report sent (e.g.
59).rstReceivedoptionalstring ≤ 10Report received.
qslSentoptionalboolean (false)QSL card sent. Default:
false.qslReceivedoptionalboolean (false)QSL card received. Default:
false.qslViaoptional"DIRECT" | "BUREAU" | "EQSL" | "ECARD"How the QSL travelled:
DIRECT(mail),BUREAU,EQSL(eQSL.cc) orECARD(other electronic card).notesoptionalstring ≤ 2000Free notes.
radiooptionalstring ≤ 80The radio used (free text).
antennaoptionalstring ≤ 80The antenna used (free text).
sendSpotoptionalbooleanWrite only, never returned:
truealso publishes the QSO as a spot on the PingDX DX cluster (once per QSO). Only for live QSOs, never for imports of old ones.idread-onlystringThe server id of the QSO.
crossConfirmedAtread-onlystring (ISO 8601) | nullSet by PingDX when the other station logged the same QSO on PingDX (cross-confirmation).
spottedAtread-onlystring (ISO 8601) | nullWhen the QSO was published as a cluster spot, or
null.createdAtread-onlystring (ISO 8601)When the QSO was first saved on PingDX.
syncedAtread-onlystring (ISO 8601)Last server-side write of the QSO (orders
GET /logs).
Synchronisation guide
A robust two-way sync between your program and PingDX in six steps:
- 1
First full download
Call
GET /logs?limit=500withoutupdatedSince, then follownextCursoruntil it isnull. Keep theserverTimeof the first page. - 2
Incremental download
Next time, call
GET /logs?updatedSince=…with the savedserverTime, follow the pages the same way, then save the newserverTime(still from the first page) only once every page has been processed. Receiving a QSO twice is harmless: apply it byclientId. - 3
Sending your QSOs
Give each QSO of your program a UUID
clientIdonce and for all, and store it. Send new or modified QSOs withPOST /logs/batch, at most 200 per request. Sending the same QSO again is safe: it updates it instead of creating a duplicate. - 4
Rejected QSOs
Read every result: if
idisnullwithpermanent: true, show the QSO to the user for correction (errorsays why) instead of retrying. Withoutpermanent, retry later. - 5
Deletions
To delete on PingDX:
DELETE /logs/{clientId}. To find QSOs deleted on PingDX:GET /logs/ids, then remove from your copy every QSO whoseclientIdis no longer listed. - 6
Conflicts
The last write wins, per
clientId: there is no merging field by field. The PingDX app picks up QSOs written through the API at its next sync (when it opens, or within a few seconds when online).
Errors and limits
Errors use the HTTP status code and a JSON body with a stable code (never a translated text):
{ "error": { "code": "API_KEYS.READ_ONLY" } }400 VALIDATION.FAILEDInvalid request: a bad parameter, a clientId that isn't a UUID, or a batch without valid entries array (1 to 200). details describes the problem.
401 API_KEYS.INVALIDMissing, unknown or revoked key (or account no longer active).
403 API_KEYS.READ_ONLYThe key is read-only: create a WRITE key to write.
404 GENERIC.NOT_FOUNDNo QSO with this clientId in your logbook.
429 GENERIC.RATE_LIMITEDToo many requests: wait a little and try again.
500 GENERIC.INTERNAL_ERRORServer error: try again later.
Per-QSO rejection reasons
In POST /logs/batch, a rejected QSO has id: null and an error code:
CLIENT_ID_INVALIDclientId is not a valid UUID.DATETIME_INVALIDqsoAt is not an ISO 8601 date-time with time zone.LOCATOR_INVALIDdxLocator or myLocator is not a 4- or 6-character Maidenhead locator.FREQUENCY_FORMAT_INVALIDfrequencyMhz must be a string with 3 decimals (27.555).FREQUENCY_OUT_OF_BAND_11MfrequencyMhz is outside 26.000–28.000 MHz.CHANNEL_OUT_OF_RANGEPMR446 channel outside 1–16.CTCSS_FORMAT_INVALIDctcssTone must look like 67.0.DCS_FORMAT_INVALIDdcsCode must look like D023N.CLIENT_ID_CONFLICTThis clientId already belongs to another member's QSO: generate a new UUID.
Other schema violations (missing field, unknown value in an enum, text too long…) return the validator's message in English, e.g. Invalid input: expected string, received undefined, also with permanent: true.
Limits
- 300 requests per minute per IP address, beyond that
429(theRetry-Afterheader says how long to wait). Group your QSOs in batches rather than sending them one by one. - 200 QSOs at most per
POST /logs/batch. - 1 to 500 QSOs per page of
GET /logs(200 by default). - 10 active keys at most per member.
Calling from a browser (CORS)
/api/v1 accepts requests from any origin (CORS), without cookies or credentials. A web page can therefore call it directly — but only with a READ key, since the code of a public page is visible to everyone.
Examples
Replace pdx_… with your key. These examples are ready to copy; only the comments are in English.
curl
# Who am I? (checks the key)
curl -H "Authorization: Bearer pdx_…" https://pingdx.org/api/v1/me
# Everything changed since my last sync
curl -H "Authorization: Bearer pdx_…" \
"https://pingdx.org/api/v1/logs?updatedSince=2026-10-01T00:00:00Z&limit=500"
# Create or update a QSO (WRITE key)
curl -X POST -H "Authorization: Bearer pdx_…" \
-H "Content-Type: application/json" \
https://pingdx.org/api/v1/logs/batch \
-d '{"entries":[{"clientId":"0b9e6c1e-5f3a-4d2b-9c7e-1a2b3c4d5e6f",
"band":"ELEVEN_METERS","frequencyMhz":"27.555","mode":"USB",
"callsignWorked":"14KM123","qsoAt":"2026-10-05T18:42:00Z",
"rstSent":"59","rstReceived":"57"}]}'
# Delete it (no body, no Content-Type)
curl -X DELETE -H "Authorization: Bearer pdx_…" \
https://pingdx.org/api/v1/logs/0b9e6c1e-5f3a-4d2b-9c7e-1a2b3c4d5e6fJavaScript (fetch)
Works in Node.js 18+ and Deno. Keep the key on the server side (environment variable).
const BASE = "https://pingdx.org/api/v1";
const KEY = process.env.PINGDX_API_KEY; // pdx_… — keep it server-side
async function call(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${KEY}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return null;
const data = await res.json();
if (!res.ok) throw new Error(`${res.status} ${data.error?.code}`);
return data;
}
// Full download, page by page
async function pullAll(updatedSince) {
const qsos = [];
let cursor = null;
let since = null;
do {
const q = new URLSearchParams({ limit: "500" });
if (updatedSince) q.set("updatedSince", updatedSince);
if (cursor) q.set("cursor", cursor);
const page = await call("GET", `/logs?${q}`);
since ??= page.serverTime; // keep the FIRST page's serverTime
qsos.push(...page.items);
cursor = page.nextCursor;
} while (cursor);
return { qsos, nextUpdatedSince: since };
}
// Push local QSOs (create or update by clientId)
const { results } = await call("POST", "/logs/batch", {
entries: [{
clientId: crypto.randomUUID(), // store it with your QSO, reuse it forever
band: "PMR446", channel: 8, ctcssTone: "67.0", mode: "FM",
callsignWorked: "PMR-ALPHA", qsoAt: new Date().toISOString(),
}],
});
for (const r of results) if (r.id === null) console.warn(r.clientId, r.error, r.permanent);Python (requests)
import os, uuid, requests
BASE = "https://pingdx.org/api/v1"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {os.environ['PINGDX_API_KEY']}" # pdx_…
def pull(updated_since=None):
"""Every QSO changed since updated_since (all of them if None)."""
qsos, cursor, next_since = [], None, None
while True:
params = {"limit": 500}
if updated_since:
params["updatedSince"] = updated_since
if cursor:
params["cursor"] = cursor
r = S.get(f"{BASE}/logs", params=params, timeout=30)
r.raise_for_status()
page = r.json()
next_since = next_since or page["serverTime"] # first page's serverTime
qsos += page["items"]
cursor = page["nextCursor"]
if not cursor:
return qsos, next_since
def push(entries):
"""Create or update up to 200 QSOs; returns the rejected ones."""
r = S.post(f"{BASE}/logs/batch", json={"entries": entries}, timeout=30)
r.raise_for_status()
return [x for x in r.json()["results"] if x["id"] is None]
rejected = push([{
"clientId": str(uuid.uuid4()), # keep it with your QSO
"band": "ELEVEN_METERS", "frequencyMhz": "27.555", "mode": "USB",
"callsignWorked": "14KM123", "qsoAt": "2026-10-05T18:42:00Z",
}])
# Deleted on PingDX? Compare clientIds with your copy
server_ids = {x["clientId"] for x in S.get(f"{BASE}/logs/ids", timeout=30).json()["items"]}A question or a program you'd like to connect? Write to us from the Contact page.