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
- They finish a game in your app and tap "Analyze on Kifubara".
- Their browser opens a Kifubara review page showing the board.
- 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:
- JSON body:
{"sgf": "(;FF[4]...", "source": "my-app", "platform": "ogs"} - Form encoded:
sgf=...&source=...&platform=... - Multipart, with the record as the file field
sgforfile - The raw record as the whole body, with
sourceandplatformas query parameters
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.