Developers

Send a game to Kifubara

One endpoint. Your app hands us a game record, we hand back a review link. The player opens it, signs in, and lands on their game with analysis already running.

POST https://kifubara.app/api/import

No key, no registration, no rate limit to negotiate. If you can make an HTTPS request, you can ship this today.

What the player sees

  1. They finish a game in your app and tap "Analyze on Kifubara".
  2. Their browser opens a Kifubara review page showing the board.
  3. If they are signed in, the game attaches to their account, analysis starts at their tier, and they land on the game page. If they are not, they see the board with a sign-in box, and land on the game page right after.

Until someone claims it the record is unlisted and belongs to nobody: it never appears in the public game browse, and only a person holding the link can open it. Claiming makes it private to that account.

Two shapes, pick the one that matches where your code runs

A. Your app has its own HTTP client

Native, desktop, mobile, or a backend. POST the record, read review_url out of the answer, hand that URL to the system browser.

The reason it is two hops: the POST comes from your process, not from the player's browser. If we answered it with a redirect, your HTTP client would be the thing that followed it, and the player would never see anything. The URL has to travel back to you so that you can open it.

B. The request is already in the player's browser

A web app, an extension, a userscript. Then we can do the redirecting for you. Add ?redirect=1 and the answer is a 302 to the review page, so the browser simply lands there. A plain HTML form is enough, no JavaScript and no API handling at all.

<form method="post" action="https://kifubara.app/api/import?redirect=1">
  <input type="hidden" name="sgf" value="(;FF[4]GM[1]SZ[19]...)">
  <input type="hidden" name="source" value="my-app">
  <input type="hidden" name="platform" value="ogs">
  <button type="submit">Analyze on Kifubara</button>
</form>

We do not send CORS headers, so a cross-origin fetch() cannot read the answer. In a browser, use the form above. In a userscript, use GM_xmlhttpRequest, which is not subject to CORS. There is a full userscript below.

Never put the record in the URL

GET /api/import?sgf=... still works and is deprecated. Do not build anything new on it, and move off it if you are using it.

The reason is not style. A request line of 4096 bytes or more is refused at the edge, before it ever reaches Kifubara: the player gets a blank "Bad Request" page, and we see nothing at all, so we cannot even tell you it happened. A 19x19 game of 250 moves is already over that limit once escaped, and a record with commentary or variations is far over it. A body has no such ceiling.

The request

Field Required What it is
sgf yes The game record.
source no Which app sent it, e.g. kifull. Lowercased, a-z 0-9 - _, 32 chars.
platform no Where the game was PLAYED, from the table below.

Send the fields any way you like. All four of these are read the same:

The record may be up to 256 KB. Anything larger is refused with 413.

source and platform answer different questions and both are worth sending. platform is where the game was played and ends up on the stored row. source is which app sent it, and it is the only thing separating your imports from everyone else's.

Platform tokens

Token Server On claim the game files under
foxwq Fox Weiqi the player's Fox games
ogs OGS their OGS games
kgs KGS their KGS games
tygem Tygem Uploaded
anything else Uploaded

Send foxwq for Fox, not fox. A token we do not recognise is kept as it arrived and the game lands in the generic Uploaded pile, which still works but files it away from the rest of that player's games from the same server.

The answer

201 when the game is new, 200 when we already had it. Both carry the same body:

{
  "game_id": "96496a96-5096-4a33-8cb7-1c3777eee75a",
  "review_url": "https://kifubara.app/review/96496a96-5096-4a33-8cb7-1c3777eee75a",
  "review_path": "/review/96496a96-5096-4a33-8cb7-1c3777eee75a",
  "deduped": false
}

Open review_url. deduped: true means the same moves had already been sent, so you get the review that already exists instead of a second copy of the game. Sending the same record twice is safe and is not an error.

With ?redirect=1, the answer is a 302 to review_path instead.

When it fails

Status error What happened
400 no_sgf No record in the request.
400 invalid_sgf It did not parse as SGF. A truncated record lands here.
400 unreadable_sgf It parsed, but the move coordinates could not be read.
400 parse_failed It parsed, but something in it is not a game we can store.
413 too_large Over 256 KB.
500 save_failed Ours. Retrying is reasonable.

Every failure carries a human-readable message. Show it: the player can usually tell from it whether the record itself is the problem. Retrying the same bytes after a 400 will fail the same way, so treat it as final and say so rather than looping.

Examples

curl

curl -X POST https://kifubara.app/api/import \
  -H "Content-Type: application/json" \
  -d '{"sgf": "(;FF[4]GM[1]SZ[19]PB[Black]PW[White]KM[6.5]RE[B+R];B[pd];W[dp])",
       "source": "my-app", "platform": "ogs"}'

Python

import requests

IMPORT_URL = "https://kifubara.app/api/import"


def kifubara_review_url(sgf_text: str, *, client: str, platform: str = "") -> str:
    """Hand a finished game to Kifubara. Returns the review URL to open in the
    player's browser. Raises RuntimeError when the record is refused."""
    resp = requests.post(
        IMPORT_URL,
        json={"sgf": sgf_text, "source": client, "platform": platform},
        timeout=20,
    )
    # 201 new, 200 we already had this game. Both carry the URL.
    if resp.status_code not in (200, 201):
        message = ""
        if resp.headers.get("content-type", "").startswith("application/json"):
            message = resp.json().get("message", "")
        raise RuntimeError(
            f"import refused {resp.status_code}: {message or resp.text[:200]}")
    return resp.json()["review_url"]


# Where the old code launched a /api/import?sgf=... URL:
import webbrowser
webbrowser.open(kifubara_review_url(sgf, client="my-app", platform="ogs"))

JavaScript

Server side, or anywhere a cross-origin read is allowed:

async function kifubaraReviewUrl(sgf, { client, platform = "" } = {}) {
  const resp = await fetch("https://kifubara.app/api/import", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ sgf, source: client, platform }),
  });
  const data = await resp.json();
  if (resp.status !== 200 && resp.status !== 201) {
    throw new Error(`import refused ${resp.status}: ${data.message || ""}`);
  }
  return data.review_url;
}

Userscript

A complete "Analyze on Kifubara" button for OGS game pages. GM_xmlhttpRequest is what makes this work from another origin, and @connect is required for it.

// ==UserScript==
// @name         Analyze on Kifubara
// @match        https://online-go.com/game/*
// @grant        GM_xmlhttpRequest
// @connect      kifubara.app
// @version      1.0
// ==/UserScript==

(function () {
  "use strict";

  function gameId() {
    const m = location.pathname.match(/\/game\/(?:view\/)?(\d+)/);
    return m ? m[1] : null;
  }

  function send(sgf) {
    GM_xmlhttpRequest({
      method: "POST",
      url: "https://kifubara.app/api/import",
      headers: { "Content-Type": "application/json" },
      data: JSON.stringify({ sgf, source: "ogs-userscript", platform: "ogs" }),
      onload: (resp) => {
        const body = JSON.parse(resp.responseText);
        if (resp.status === 200 || resp.status === 201) {
          window.open(body.review_url, "_blank");
        } else {
          alert("Kifubara could not take that game: " + (body.message || resp.status));
        }
      },
      onerror: () => alert("Could not reach Kifubara."),
    });
  }

  const button = document.createElement("button");
  button.textContent = "Analyze on Kifubara";
  button.style.cssText = "position:fixed;right:16px;bottom:16px;z-index:9999;padding:8px 14px";
  button.addEventListener("click", async () => {
    const id = gameId();
    if (!id) return;
    button.disabled = true;
    try {
      // Same origin, so a plain fetch is fine for this half.
      const sgf = await fetch(`/api/v1/games/${id}/sgf`).then((r) => r.text());
      send(sgf);
    } finally {
      button.disabled = false;
    }
  });
  document.body.appendChild(button);
})();

Dart

Future<Uri> kifubaraReviewUrl(String sgf,
    {required String client, String? platform}) async {
  final resp = await http.post(
    Uri.parse('https://kifubara.app/api/import'),
    body: {'sgf': sgf, 'source': client, if (platform != null) 'platform': platform},
  ).timeout(const Duration(seconds: 20));

  // 201 new, 200 we already had this game. Both carry the URL.
  if (resp.statusCode != 200 && resp.statusCode != 201) {
    throw Exception('import refused ${resp.statusCode}: ${resp.body}');
  }
  return Uri.parse((jsonDecode(resp.body) as Map<String, dynamic>)['review_url']);
}

Then launchUrl(review, mode: LaunchMode.externalApplication).

Questions

Write to us through the contact page and say which app you are building. If you tell us the source token you are sending, we can tell you whether your imports are arriving and whether players are opening them.

Sign in to Kifubara

Save variations, favorite games, and sync your OGS library.

or
Continue with Google

By continuing you agree to our Terms and Privacy Policy.