{
  "openapi": "3.1.0",
  "info": {
    "title": "World Radio League API",
    "version": "1.0.0",
    "summary": "Read and write amateur radio contacts in a World Radio League logbook.",
    "description": "Log contacts into World Radio League from your own software, and read them back.\n\n## Getting a key\n\nSign in to World Radio League, open **Integrations → Developer API**, and choose\n**Generate API key**. The key is shown once and stored hashed, so it cannot be\nrecovered afterwards. One key covers your whole account — it is not per logbook.\n\nAPI access is included with any paid membership.\n\n## Authenticating\n\nSend the key as `Authorization: Bearer wrl_live_...`, or as `X-API-Key`. Both work.\nIf you send both they must match.\n\n**Keep the key server-side.** These endpoints deliberately send no CORS headers, so a\nbrowser cannot call them directly — an API key in a front-end bundle is a leaked key.\n\n## Responses\n\nEvery response, success or failure, is `{ \"data\": ..., \"meta\": ..., \"error\": ... }`,\nso one unwrapper handles everything. Error `code` values are stable and safe to branch\non; `message` text is not. Every response carries `X-Request-Id` — quote it in support\nrequests.\n\n## Rate limits\n\n60 writes and 120 reads per minute; 5,000 writes and 20,000 reads per day.\n\n**Reads and writes have separate budgets.** Reading does not consume write quota, and\na write does not consume read quota. `GET`, `HEAD` and unmatched paths count as reads;\n`POST`, `PATCH` and `DELETE` count as writes.\n\nEvery response issued after your quota was charged carries `X-RateLimit-Limit`,\n`X-RateLimit-Remaining` and `X-RateLimit-Reset` — errors included, because a rejected\nrequest that reached validation has already been charged. The values always describe\nthe budget for the kind of request you just made, so `Remaining` on a `POST` response\nis your remaining writes, not your remaining reads. Track the two separately.\n\nA `429` carries `Retry-After` in seconds, computed from whichever window actually\nblocked you — honour it rather than retrying on a fixed timer.\n\n## One contact per request\n\n`POST /v1/contacts` takes exactly one contact. To upload many at once, use the ADIF\nimport inside World Radio League, which is built for that volume.\n\n## Enrichment\n\nEnrichment is the process by which World Radio League derives missing information from\nthe data you provide. We prioritize the data you send us, but in the case that certain data such as DXCC ID is missing, we will attempt to derive it.\n\nIt happens a moment after the contact is stored, not during the request. A newly created\ncontact therefore comes back with `enrichment: \"pending\"`; re-read it a few seconds\nlater and `dxcc` and `distance` will be filled in.\n\nAnything you supply yourself is never overwritten — if you already know the grid square\nor the operator's name, send it and it is kept as-is.",
    "contact": {
      "name": "World Radio League",
      "url": "https://worldradioleague.com/developer"
    }
  },
  "servers": [
    {
      "url": "https://api.worldradioleague.com",
      "description": "Production"
    },
    {
      "url": "https://ebuveblscfmqpztairyk.supabase.co/functions/v1/public-api",
      "description": "Direct Supabase URL (no custom domain)"
    }
  ],
  "security": [
    {
      "BearerApiKey": []
    },
    {
      "HeaderApiKey": []
    }
  ],
  "tags": [
    {
      "name": "Account",
      "description": "Who the key belongs to, and what it may do."
    },
    {
      "name": "Contacts",
      "description": "QSOs — the primary resource."
    },
    {
      "name": "Logbooks",
      "description": "Containers for contacts. Each contact belongs to one."
    }
  ],
  "paths": {
    "/v1/me": {
      "get": {
        "tags": [
          "Account"
        ],
        "operationId": "getMe",
        "summary": "Identity and quota for the current key",
        "description": "Confirms a key works and reports what it may do, without writing anything. The first call worth making after generating a key.",
        "responses": {
          "200": {
            "description": "Key details.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Me"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/contacts": {
      "get": {
        "tags": [
          "Contacts"
        ],
        "operationId": "listContacts",
        "summary": "List your contacts",
        "description": "Newest first. Combine filters freely.",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "name": "logbookId",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Only contacts in this logbook."
          },
          {
            "name": "call",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Exact match on the station worked. Case-insensitive.",
            "example": "W1AW"
          },
          {
            "name": "mode",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Exact match. Case-insensitive.",
            "example": "FT8"
          },
          {
            "name": "band",
            "in": "query",
            "schema": {
              "type": "number"
            },
            "description": "Band in metres, e.g. 20 for 20m, 0.7 for 70cm.",
            "example": 20
          },
          {
            "name": "since",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Contacts at or after this UTC time."
          },
          {
            "name": "until",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Contacts at or before this UTC time."
          }
        ],
        "responses": {
          "200": {
            "description": "A page of contacts.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Contact"
                          }
                        },
                        "meta": {
                          "$ref": "#/components/schemas/PageMeta"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "post": {
        "tags": [
          "Contacts"
        ],
        "operationId": "createContact",
        "summary": "Log one contact",
        "description": "Logs a single QSO. The required fields are `programId`, `call`, `timestamp`, `freq`, `band` and `mode`.\n\n`logbookId` is optional — omit it and the contact goes to your default logbook, so a new integration needs no setup call. Sending it is recommended once you know which logbook you want.\n\nYour own callsign is worked out for you if you do not send `stationCallsign` — first\nthe logbook's default, then the callsign on your profile. It matters: it is what\nduplicate detection and contact confirmation match on.\n\nSending an array is rejected. Use the ADIF import for bulk uploads.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContactCreate"
              },
              "examples": {
                "minimal": {
                  "summary": "The six required fields",
                  "value": {
                    "programId": "Ham2k",
                    "call": "W1AW",
                    "timestamp": "2026-08-05T14:35:00Z",
                    "freq": 14.074,
                    "band": "20m",
                    "mode": "FT8"
                  }
                },
                "full": {
                  "summary": "With signal reports and details you already know",
                  "value": {
                    "programId": "Ham2k",
                    "call": "W1AW",
                    "timestamp": "2026-08-05T14:35:00Z",
                    "freq": 14.074,
                    "band": "20m",
                    "mode": "FT8",
                    "rstSent": "-12",
                    "rstRcvd": "-08",
                    "txPwr": "100",
                    "stationCallsign": "K1ABC",
                    "name": "ARRL HQ",
                    "gridsquare": "FN31pr",
                    "notes": "First FT8 contact of the day"
                  }
                },
                "adifTimestamp": {
                  "summary": "ADIF-style date and time",
                  "value": {
                    "programId": "Ham2k",
                    "call": "W1AW",
                    "timestamp": {
                      "qsoDate": "20260805",
                      "timeOn": "1435"
                    },
                    "freq": 14.074,
                    "band": "20m",
                    "mode": "FT8"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Logged. Enrichment fields are still empty — re-read the contact in a few seconds.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/CreatedContact"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "The body was not valid JSON, or contained unknown fields.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "data": null,
                  "meta": null,
                  "error": {
                    "code": "VALIDATION_ERROR",
                    "message": "Unrecognised field \"grid_square\".",
                    "field": "grid_square"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "description": "No such logbook, or it is not yours.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "data": null,
                  "meta": null,
                  "error": {
                    "code": "NOT_FOUND",
                    "message": "No such logbook.",
                    "field": "logbookId"
                  }
                }
              }
            }
          },
          "409": {
            "description": "The logbook is locked.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "data": null,
                  "meta": null,
                  "error": {
                    "code": "CONFLICT",
                    "message": "This logbook is locked and cannot accept new contacts.",
                    "field": "logbookId"
                  }
                }
              }
            }
          },
          "413": {
            "description": "An array was sent, or the body was too large.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "data": null,
                  "meta": null,
                  "error": {
                    "code": "BATCH_TOO_LARGE",
                    "message": "This endpoint accepts a single contact per request.",
                    "hint": "To upload many contacts at once, use the ADIF import in World Radio League."
                  }
                }
              }
            }
          },
          "422": {
            "description": "A field was invalid, or `logbookId` was omitted and no destination could be resolved. Branch on `code`: `VALIDATION_ERROR` means fix the payload, `LOGBOOK_REQUIRED` means the account needs a default logbook chosen.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "data": null,
                  "meta": null,
                  "error": {
                    "code": "LOGBOOK_REQUIRED",
                    "message": "This account has several logbooks and no default, so the destination is ambiguous.",
                    "field": "logbookId",
                    "hint": "Send `logbookId` explicitly, or choose a default logbook under Integrations → Developer API in World Radio League."
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/contacts/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Id"
        }
      ],
      "get": {
        "tags": [
          "Contacts"
        ],
        "operationId": "getContact",
        "summary": "Fetch one contact",
        "description": "Use this after creating a contact to pick up the enriched fields (`dxcc` and `distance`), which World Radio League derives from the callsign shortly after the contact is stored.",
        "responses": {
          "200": {
            "description": "The contact.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Contact"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "patch": {
        "tags": [
          "Contacts"
        ],
        "operationId": "updateContact",
        "summary": "Correct a contact",
        "description": "Only the fields you send are changed.\n\nA contact cannot be moved to a different logbook — `logbookId` is rejected. Moving\none would change contest scoring and duplicate handling in ways that cannot be\nrecalculated safely. Delete it and create it in the target logbook instead.\n\nChanging the time, either callsign, the band or the mode re-runs contact\nconfirmation against other operators' logs.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ContactUpdate"
              },
              "example": {
                "rstRcvd": "-05",
                "notes": "Corrected report"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Contact"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "description": "Invalid value, or an attempt to move logbooks.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "data": null,
                  "meta": null,
                  "error": {
                    "code": "FIELD_NOT_UPDATABLE",
                    "message": "A contact cannot be moved between logbooks. Delete it and create it in the target logbook.",
                    "field": "logbookId"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "delete": {
        "tags": [
          "Contacts"
        ],
        "operationId": "deleteContact",
        "summary": "Delete a contact",
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/DeleteResult"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/logbooks": {
      "get": {
        "tags": [
          "Logbooks"
        ],
        "operationId": "listLogbooks",
        "summary": "List your logbooks",
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "A page of logbooks, newest first.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Logbook"
                          }
                        },
                        "meta": {
                          "$ref": "#/components/schemas/PageMeta"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "post": {
        "tags": [
          "Logbooks"
        ],
        "operationId": "createLogbook",
        "summary": "Create a logbook",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LogbookCreate"
              },
              "example": {
                "name": "POTA activations",
                "defaultCallSign": "K1ABC"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Logbook"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/v1/logbooks/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/Id"
        }
      ],
      "get": {
        "tags": [
          "Logbooks"
        ],
        "operationId": "getLogbook",
        "summary": "Fetch one logbook",
        "responses": {
          "200": {
            "description": "The logbook.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Logbook"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "patch": {
        "tags": [
          "Logbooks"
        ],
        "operationId": "updateLogbook",
        "summary": "Update a logbook",
        "description": "Only the fields you send are changed.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LogbookUpdate"
              },
              "example": {
                "description": "Parks on the Air, 2026 season"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/Logbook"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationError"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "delete": {
        "tags": [
          "Logbooks"
        ],
        "operationId": "deleteLogbook",
        "summary": "Delete an empty logbook",
        "description": "The logbook must contain no contacts. Deleting a logbook full of contacts would cascade into a large amount of background work, so it is not offered here — delete the contacts first, or remove the logbook inside World Radio League, which handles bulk deletion in the background.",
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/DeleteResult"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "The logbook still contains contacts.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "example": {
                  "data": null,
                  "meta": null,
                  "error": {
                    "code": "LOGBOOK_NOT_EMPTY",
                    "message": "This logbook still holds 412 contacts.",
                    "hint": "Delete its contacts first, or remove the logbook in World Radio League."
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "BearerApiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "`Authorization: Bearer wrl_live_...`"
      },
      "HeaderApiKey": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "`X-API-Key: wrl_live_...` — equivalent to the bearer form."
      }
    },
    "parameters": {
      "Id": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "format": "uuid"
        },
        "description": "Resource id. Unknown ids and ids belonging to another account both return 404."
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 100,
          "default": 25
        },
        "description": "Rows per page."
      },
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "schema": {
          "type": "string"
        },
        "description": "Pass `meta.nextCursor` from the previous response verbatim to get the next page. A null `nextCursor` means you have reached the end.\n\nCursors are **opaque** — do not parse, construct or modify them. They encode both the sort timestamp and a unique tiebreaker, which is what stops contacts sharing a timestamp from being skipped at a page boundary; ADIF minute-precision times make that routine. A hand-built cursor cannot express a boundary safely and is rejected with `VALIDATION_ERROR`."
      }
    },
    "schemas": {
      "Envelope": {
        "type": "object",
        "required": [
          "data",
          "meta",
          "error"
        ],
        "properties": {
          "data": {
            "description": "The payload, or null on failure."
          },
          "meta": {
            "type": [
              "object",
              "null"
            ],
            "description": "Pagination details and validation warnings."
          },
          "error": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/Error"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "ErrorEnvelope": {
        "type": "object",
        "required": [
          "data",
          "meta",
          "error"
        ],
        "properties": {
          "data": {
            "type": "null"
          },
          "meta": {
            "type": "null"
          },
          "error": {
            "$ref": "#/components/schemas/Error"
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "code",
          "message"
        ],
        "properties": {
          "code": {
            "type": "string",
            "description": "Stable identifier. Branch on this, not on `message`.",
            "enum": [
              "MISSING_CREDENTIALS",
              "INVALID_KEY",
              "KEY_REVOKED",
              "MEMBERSHIP_REQUIRED",
              "INSUFFICIENT_SCOPE",
              "IP_NOT_ALLOWED",
              "MALFORMED_JSON",
              "NOT_FOUND",
              "METHOD_NOT_ALLOWED",
              "LOGBOOK_NOT_EMPTY",
              "CONFLICT",
              "PAYLOAD_TOO_LARGE",
              "BATCH_TOO_LARGE",
              "VALIDATION_ERROR",
              "RATE_LIMITED",
              "INTERNAL_ERROR",
              "API_DISABLED",
              "POLICY_UNAVAILABLE"
            ]
          },
          "message": {
            "type": "string",
            "description": "Human-readable. Wording may change."
          },
          "field": {
            "type": "string",
            "description": "The field at fault, when there is one."
          },
          "hint": {
            "type": "string",
            "description": "What to do about it."
          },
          "requestId": {
            "type": "string",
            "format": "uuid",
            "description": "Quote this in support requests."
          }
        }
      },
      "PageMeta": {
        "type": "object",
        "properties": {
          "count": {
            "type": "integer",
            "description": "Rows in this response."
          },
          "limit": {
            "type": "integer"
          },
          "nextCursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Opaque. Pass as `cursor` for the next page; null means no more rows. Results are ordered by timestamp descending, tiebroken by id, so paging is stable and never skips or repeats a row."
          }
        }
      },
      "Me": {
        "type": "object",
        "properties": {
          "uid": {
            "type": "string",
            "format": "uuid",
            "description": "Your World Radio League user id."
          },
          "environment": {
            "type": "string",
            "enum": [
              "live",
              "test"
            ]
          },
          "scopes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "membershipTier": {
            "type": "string",
            "example": "premium"
          },
          "defaultLogbook": {
            "type": "object",
            "description": "Where a contact goes when you omit `logbookId`. Check this once at setup: a null `logbookId` means such a request will be refused with `LOGBOOK_REQUIRED`, and you can tell the operator what to fix before they try to log a QSO.",
            "properties": {
              "logbookId": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "uuid",
                "description": "Null when no destination can be resolved."
              },
              "resolution": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "configured",
                  "sole",
                  "ambiguous",
                  "none",
                  null
                ],
                "description": "`configured` — explicitly chosen. `sole` — the only usable logbook. `ambiguous` — several exist and none is chosen, so send `logbookId`. `none` — no unlocked logbook exists; create one first."
              }
            }
          },
          "limits": {
            "type": "object",
            "properties": {
              "writePerMin": {
                "type": "integer",
                "example": 60
              },
              "readPerMin": {
                "type": "integer",
                "example": 120
              },
              "writePerDay": {
                "type": "integer",
                "example": 5000
              },
              "readPerDay": {
                "type": "integer",
                "example": 20000
              },
              "maxContactsPerRequest": {
                "type": "integer",
                "example": 1,
                "description": "Contacts accepted per POST. Currently always 1."
              }
            }
          },
          "quota": {
            "type": "object",
            "description": "Read budget only. GET /v1/me is itself a read, so it reports the counters it just charged; your write budget is separate and unaffected by calling this endpoint. Read the write figures from the X-RateLimit-* headers on your next write.",
            "properties": {
              "readRemainingThisMinute": {
                "type": "integer"
              },
              "readRemainingToday": {
                "type": "integer"
              },
              "resetAt": {
                "type": "string",
                "format": "date-time"
              }
            }
          }
        }
      },
      "Logbook": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "defaultCallSign": {
            "type": [
              "string",
              "null"
            ]
          },
          "defaultFrequency": {
            "type": [
              "string",
              "null"
            ]
          },
          "defaultMode": {
            "type": [
              "string",
              "null"
            ]
          },
          "defaultBand": {
            "type": [
              "string",
              "null"
            ]
          },
          "defaultPower": {
            "type": [
              "string",
              "null"
            ]
          },
          "isLocked": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "LogbookCreate": {
        "type": "object",
        "required": [
          "name"
        ],
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 100
          },
          "description": {
            "type": "string"
          },
          "defaultCallSign": {
            "type": "string",
            "maxLength": 20,
            "description": "Used as `myCallsign` when a contact does not supply one."
          },
          "defaultFrequency": {
            "type": "string",
            "maxLength": 20
          },
          "defaultMode": {
            "type": "string",
            "maxLength": 50
          },
          "defaultBand": {
            "type": "string",
            "maxLength": 20
          },
          "defaultPower": {
            "type": "string",
            "maxLength": 20
          }
        }
      },
      "LogbookUpdate": {
        "type": "object",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 100
          },
          "description": {
            "type": "string"
          },
          "defaultCallSign": {
            "type": "string",
            "maxLength": 20
          },
          "defaultFrequency": {
            "type": "string",
            "maxLength": 20
          },
          "defaultMode": {
            "type": "string",
            "maxLength": 50
          },
          "defaultBand": {
            "type": "string",
            "maxLength": 20
          },
          "defaultPower": {
            "type": "string",
            "maxLength": 20
          }
        }
      },
      "QsoTimestamp": {
        "description": "When the contact happened, in UTC. An ISO-8601 string without a timezone is treated as UTC — amateur radio logs UTC by convention, so a local time would be silently wrong.\n\nADIF splits this across `QSO_DATE` and `TIME_ON` and has no combined field; one instant is friendlier for a JSON API, so `timestamp` is the one place this API deliberately leaves the ADIF vocabulary. The ADIF pair is accepted too, under ADIF's own names, for callers translating straight from an ADIF record.",
        "oneOf": [
          {
            "type": "string",
            "format": "date-time",
            "examples": [
              "2026-08-05T14:35:00Z"
            ]
          },
          {
            "type": "object",
            "required": [
              "qsoDate",
              "timeOn"
            ],
            "properties": {
              "qsoDate": {
                "type": "string",
                "pattern": "^\\d{8}$",
                "examples": [
                  "20260805"
                ],
                "description": "ADIF QSO_DATE — YYYYMMDD."
              },
              "timeOn": {
                "type": "string",
                "pattern": "^\\d{4}(\\d{2})?$",
                "examples": [
                  "1435"
                ],
                "description": "ADIF TIME_ON — HHMM or HHMMSS. HHMM is padded to :00 seconds."
              }
            }
          }
        ]
      },
      "ContactCreate": {
        "type": "object",
        "required": [
          "programId",
          "call",
          "timestamp",
          "freq",
          "band",
          "mode"
        ],
        "additionalProperties": false,
        "description": "Unknown fields are rejected rather than ignored, so a misspelled name never silently loses data.",
        "properties": {
          "programId": {
            "type": "string",
            "maxLength": 64,
            "examples": [
              "Ham2k"
            ],
            "description": "**Required.** The logger, converter or utility posting this contact — the equivalent of ADIF `PROGRAMID`. Send a short program name; it is recorded against the contact so the operator can see which application logged a QSO, and is not returned in responses."
          },
          "logbookId": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Optional but recommended. Must be one of your own logbooks.\n\nOmit it (or send null) and the contact goes to your default logbook: the one chosen under Integrations → Developer API, or your only logbook if you have just one. If you have several and have chosen none, the request is refused with `LOGBOOK_REQUIRED` rather than guessed at — a contact cannot be moved between logbooks afterwards, so a wrong guess would be permanent.\n\n`meta.logbookResolution` on the response reports which rule applied, and `GET /v1/me` reports your default before you write anything."
          },
          "call": {
            "type": "string",
            "description": "The station you worked. Case is normalised. Portable and reciprocal forms are accepted: W1AW, W1AW/4, DL/W1AW, VP2E/W1AW/QRP.",
            "examples": [
              "W1AW"
            ]
          },
          "timestamp": {
            "$ref": "#/components/schemas/QsoTimestamp"
          },
          "freq": {
            "type": "number",
            "description": "MHz.",
            "examples": [
              14.074
            ]
          },
          "band": {
            "type": [
              "string",
              "number"
            ],
            "description": "Band as \"20m\", \"20\", 20 or \"70cm\". Must be a band World Radio League recognises (2200m down to 23cm); an unrecognised or malformed value is rejected. If it disagrees with the frequency, the frequency wins and a warning is returned in `meta.warnings`.",
            "examples": [
              "20m"
            ]
          },
          "mode": {
            "type": "string",
            "description": "Mode or submode of the contact. World Radio League stores a single mode field, so send the submode when you want to be specific. For JS8 contacts it is preferred to send \"JS8\" — World Radio League understands that as ADIF MODE = MFSK, SUBMODE = JS8.",
            "examples": [
              "FT8",
              "JS8"
            ]
          },
          "rstSent": {
            "type": "string",
            "maxLength": 10
          },
          "rstRcvd": {
            "type": "string",
            "maxLength": 10
          },
          "txPwr": {
            "type": "string",
            "maxLength": 20,
            "description": "Watts."
          },
          "notes": {
            "type": "string",
            "maxLength": 2000
          },
          "stationCallsign": {
            "type": "string",
            "description": "Station callsign. Defaults to the account callsign if not specified: the logbook's default callsign is used first, then the callsign on your profile."
          },
          "myGridsquare": {
            "type": "string",
            "pattern": "^[A-Ra-r]{2}(?:[0-9]{2}(?:[A-Xa-x]{2}(?:[0-9]{2})?)?)?$",
            "description": "Your own Maidenhead locator: 2, 4, 6 or 8 characters (e.g. \"FN31\", \"FN31pr\")."
          },
          "name": {
            "type": "string",
            "maxLength": 255,
            "description": "The contacted station operator's name."
          },
          "gridsquare": {
            "type": "string",
            "pattern": "^[A-Ra-r]{2}(?:[0-9]{2}(?:[A-Xa-x]{2}(?:[0-9]{2})?)?)?$",
            "description": "Maidenhead locator of the contacted station: 2, 4, 6 or 8 characters (e.g. \"FN31\", \"FN31pr\")."
          },
          "qth": {
            "type": "string",
            "maxLength": 100
          },
          "state": {
            "type": "string",
            "maxLength": 50,
            "description": "State/province of the contacted station. An abbreviation is preferred where possible (\"TX\" rather than \"Texas\")."
          },
          "operator": {
            "type": "string",
            "maxLength": 100,
            "description": "Callsign of the operator who is operating the station. Defaults to the account callsign if not specified."
          }
        }
      },
      "ContactUpdate": {
        "type": "object",
        "minProperties": 1,
        "additionalProperties": false,
        "description": "`logbookId` is not accepted — a contact cannot change logbook.",
        "properties": {
          "call": {
            "type": "string"
          },
          "timestamp": {
            "$ref": "#/components/schemas/QsoTimestamp"
          },
          "freq": {
            "type": "number"
          },
          "band": {
            "type": [
              "string",
              "number"
            ],
            "description": "Must be a recognised band. Sent alone (no `freq`), it is validated directly, since there is no frequency to reconcile it against."
          },
          "mode": {
            "type": "string",
            "description": "Mode or submode of the contact. World Radio League stores a single mode field, so send the submode when you want to be specific. For JS8 contacts it is preferred to send \"JS8\" — World Radio League understands that as ADIF MODE = MFSK, SUBMODE = JS8."
          },
          "rstSent": {
            "type": "string",
            "maxLength": 10
          },
          "rstRcvd": {
            "type": "string",
            "maxLength": 10
          },
          "txPwr": {
            "type": "string",
            "maxLength": 20
          },
          "notes": {
            "type": "string",
            "maxLength": 2000
          },
          "stationCallsign": {
            "type": "string",
            "description": "Station callsign. Defaults to the account callsign if not specified: the logbook's default callsign is used first, then the callsign on your profile."
          },
          "myGridsquare": {
            "type": "string",
            "pattern": "^[A-Ra-r]{2}(?:[0-9]{2}(?:[A-Xa-x]{2}(?:[0-9]{2})?)?)?$",
            "description": "Your own Maidenhead locator: 2, 4, 6 or 8 characters (e.g. \"FN31\", \"FN31pr\")."
          },
          "name": {
            "type": "string",
            "maxLength": 255,
            "description": "The contacted station operator's name."
          },
          "gridsquare": {
            "type": "string",
            "pattern": "^[A-Ra-r]{2}(?:[0-9]{2}(?:[A-Xa-x]{2}(?:[0-9]{2})?)?)?$",
            "description": "Maidenhead locator of the contacted station: 2, 4, 6 or 8 characters (e.g. \"FN31\", \"FN31pr\")."
          },
          "qth": {
            "type": "string",
            "maxLength": 100
          },
          "state": {
            "type": "string",
            "maxLength": 50,
            "description": "State/province of the contacted station. An abbreviation is preferred where possible (\"TX\" rather than \"Texas\")."
          },
          "operator": {
            "type": "string",
            "maxLength": 100,
            "description": "Callsign of the operator who is operating the station. Defaults to the account callsign if not specified."
          }
        }
      },
      "Contact": {
        "type": "object",
        "description": "Fields marked *enriched* are derived by World Radio League from the data you provided (see Enrichment in the overview) shortly after the contact is created. Anything you supplied yourself is never overwritten.\n\n`programId` is not listed here: it is required on create and is recorded against the contact, but it is never returned. See `ContactCreate`.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "logbookId": {
            "type": "string",
            "format": "uuid"
          },
          "call": {
            "type": "string",
            "description": "The station worked."
          },
          "stationCallsign": {
            "type": [
              "string",
              "null"
            ],
            "description": "Station callsign."
          },
          "timestamp": {
            "type": "string",
            "format": "date-time",
            "description": "UTC."
          },
          "freq": {
            "type": [
              "number",
              "null"
            ],
            "description": "MHz."
          },
          "band": {
            "type": [
              "number",
              "null"
            ],
            "description": "Metres, e.g. 20."
          },
          "mode": {
            "type": [
              "string",
              "null"
            ],
            "description": "Mode or submode of the contact. World Radio League stores a single mode field, so send the submode when you want to be specific. For JS8 contacts it is preferred to send \"JS8\" — World Radio League understands that as ADIF MODE = MFSK, SUBMODE = JS8."
          },
          "txPwr": {
            "type": [
              "string",
              "null"
            ]
          },
          "rstSent": {
            "type": [
              "string",
              "null"
            ]
          },
          "rstRcvd": {
            "type": [
              "string",
              "null"
            ],
            "description": "ADIF RST_RCVD."
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "operator": {
            "type": [
              "string",
              "null"
            ],
            "description": "Callsign of the operator who is operating the station."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "The contacted station operator's name."
          },
          "gridsquare": {
            "type": [
              "string",
              "null"
            ],
            "description": "Maidenhead locator of the contacted station: 2, 4, 6 or 8 characters (e.g. \"FN31\", \"FN31pr\")."
          },
          "qth": {
            "type": [
              "string",
              "null"
            ]
          },
          "state": {
            "type": [
              "string",
              "null"
            ],
            "description": "State/province of the contacted station. An abbreviation is preferred where possible (\"TX\" rather than \"Texas\")."
          },
          "dxcc": {
            "type": [
              "integer",
              "null"
            ],
            "description": "*enriched* — ADIF DXCC entity code. Country, continent and flag are all derivable from this and are deliberately not duplicated here."
          },
          "myGridsquare": {
            "type": [
              "string",
              "null"
            ],
            "description": "Your own Maidenhead locator: 2, 4, 6 or 8 characters (e.g. \"FN31\", \"FN31pr\")."
          },
          "distance": {
            "type": [
              "number",
              "null"
            ],
            "description": "*enriched* — great-circle distance."
          },
          "isDuplicate": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Set by World Radio League when it matches another contact in your log."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          }
        }
      },
      "CreatedContact": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Contact"
          },
          {
            "type": "object",
            "properties": {
              "enrichment": {
                "type": "string",
                "enum": [
                  "pending"
                ],
                "description": "Always `pending` on create: `dxcc` and `distance` are derived from the callsign within a few seconds of the contact being stored. Re-read the contact to see them."
              }
            }
          }
        ]
      },
      "DeleteResult": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "deleted": {
            "type": "boolean",
            "enum": [
              true
            ]
          }
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "The key is missing, malformed, unknown, or revoked. `KEY_REVOKED` means generate a new one.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "data": null,
              "meta": null,
              "error": {
                "code": "INVALID_KEY",
                "message": "The API key is not valid.",
                "hint": "Check for truncation or extra whitespace when copying the key.",
                "requestId": "379f8bce-f716-45d0-bbb8-c64193941183"
              }
            }
          }
        }
      },
      "Forbidden": {
        "description": "Authenticated, but not permitted: no paid membership, a missing scope, or a source address the key is not allowed to be used from.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "data": null,
              "meta": null,
              "error": {
                "code": "MEMBERSHIP_REQUIRED",
                "message": "API access requires a World Radio League membership. This account's tier is \"free\".",
                "hint": "The key stays valid — renewing the membership restores access."
              }
            }
          }
        }
      },
      "NotFound": {
        "description": "No such resource. Returned identically whether the id does not exist or belongs to another account.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "data": null,
              "meta": null,
              "error": {
                "code": "NOT_FOUND",
                "message": "No such contact."
              }
            }
          }
        }
      },
      "ValidationError": {
        "description": "A field failed validation.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "data": null,
              "meta": null,
              "error": {
                "code": "VALIDATION_ERROR",
                "message": "Expected an ISO-8601 UTC timestamp (e.g. \"2026-07-30T14:35:00Z\") or ADIF form.",
                "field": "timestamp"
              }
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests. Honour `Retry-After` — it is computed from the window that actually blocked you, so a daily exhaustion does not tell you to retry in seconds.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            },
            "description": "Seconds."
          },
          "X-RateLimit-Limit": {
            "schema": {
              "type": "integer"
            },
            "description": "Per-minute ceiling for this request's kind — the write limit on a write, the read limit on a read."
          },
          "X-RateLimit-Remaining": {
            "schema": {
              "type": "integer"
            },
            "description": "Remaining units in that same budget. Reads and writes are counted separately."
          },
          "X-RateLimit-Reset": {
            "schema": {
              "type": "integer"
            },
            "description": "Unix seconds."
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "data": null,
              "meta": null,
              "error": {
                "code": "RATE_LIMITED",
                "message": "Daily API quota exhausted.",
                "hint": "Retry after 2026-08-06T00:00:00.000Z."
              }
            }
          }
        }
      },
      "InternalError": {
        "description": "Something went wrong on our side. Quote `requestId` when reporting it.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ErrorEnvelope"
            },
            "example": {
              "data": null,
              "meta": null,
              "error": {
                "code": "INTERNAL_ERROR",
                "message": "An unexpected error occurred.",
                "requestId": "379f8bce-f716-45d0-bbb8-c64193941183"
              }
            }
          }
        }
      }
    }
  }
}