Samples
The same two things in every language: prove the key with GET /v1/ping, then land a sheet of stock as one sync. Copy the one you run, put a sandbox key in it, and go.
Each file is also served on its own: SyncSheet.cs, sync-sheet.mjs, sync-sheet.ps1, sync-sheet.py, sync-sheet.sh.
C#
// Prove the key, then land a sheet of stock as one sync. .NET 8, no packages.
//
// FREEPORT_KEY=fp_test_... dotnet run -- stock-sheet-template.csv
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
var baseUrl = Environment.GetEnvironmentVariable("FREEPORT_API") ?? "https://sandbox-api.freeport.gg/v1";
var key = Environment.GetEnvironmentVariable("FREEPORT_KEY")
?? throw new InvalidOperationException("set FREEPORT_KEY to your fp_test_ or fp_live_ key");
var sheet = args.Length > 0 ? args[0] : "stock-sheet-template.csv";
var numbers = new HashSet<string> { "price_per_qty", "min_qty", "max_qty", "stock_qty", "low_stock_alert_qty", "delivery_eta_minutes" };
using var http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", key);
// 1. Who am I?
Console.WriteLine(await Call(HttpMethod.Get, "ping"));
// 2. The sheet, one line per row. Tiers are "min_qty:price;min_qty:price".
var rows = File.ReadAllLines(sheet).Where(l => l.Length > 0).Select(ParseCsv).ToList();
var header = rows[0];
var lines = new JsonArray();
foreach (var cells in rows.Skip(1))
{
var line = new JsonObject();
for (var i = 0; i < header.Count; i++)
{
var value = i < cells.Count ? cells[i] : "";
if (value.Length == 0 || header[i] == "tiers") continue;
line[header[i]] = numbers.Contains(header[i]) ? JsonValue.Create(int.Parse(value)) : JsonValue.Create(value);
}
var tiers = header.IndexOf("tiers") is var t && t >= 0 && t < cells.Count ? cells[t] : "";
if (tiers.Length > 0)
{
line["tiers"] = new JsonArray(tiers.Split(';').Select(r =>
{
var parts = r.Split(':');
return (JsonNode)new JsonObject { ["min_qty"] = int.Parse(parts[0]), ["price"] = parts[1] };
}).ToArray());
}
lines.Add(line);
}
// 3. Open, stage (up to a thousand a call), commit with an idempotency key.
var sync = await Call(HttpMethod.Post, "stock/syncs", new JsonObject { ["mode"] = "replace" });
var syncId = sync!["id"]!.GetValue<string>();
for (var start = 0; start < lines.Count; start += 1000)
{
var chunk = new JsonArray(lines.Skip(start).Take(1000).Select(n => JsonNode.Parse(n!.ToJsonString())).ToArray());
var staged = await Call(HttpMethod.Put, $"stock/syncs/{syncId}/lines", new JsonObject { ["lines"] = chunk });
var results = staged!["results"]!.AsArray();
Console.WriteLine($"staged {results.Count(r => r!["status"]!.GetValue<string>() == "staged")} of {results.Count}");
}
await Call(HttpMethod.Post, $"stock/syncs/{syncId}/commit", null, ("Idempotency-Key", $"commit-{syncId}"));
// 4. The worker applies it; poll the sync, not the listings.
while (true)
{
var state = await Call(HttpMethod.Get, $"stock/syncs/{syncId}");
if (state!["status"]!.GetValue<string>() != "committing") { Console.WriteLine(state["summary"]); break; }
await Task.Delay(1000);
}
// 5. Any line that failed says why.
var failed = await Call(HttpMethod.Get, $"stock/syncs/{syncId}/lines?action=failed");
foreach (var line in failed!["data"]!.AsArray()) Console.WriteLine($"{line!["external_id"]}: {line["error"]}");
async Task<JsonNode?> Call(HttpMethod method, string path, JsonNode? body = null, (string, string)? header = null)
{
using var request = new HttpRequestMessage(method, path);
if (body is not null) request.Content = JsonContent.Create(body);
if (header is { } h) request.Headers.Add(h.Item1, h.Item2);
using var response = await http.SendAsync(request);
var json = await response.Content.ReadFromJsonAsync<JsonNode>();
if (!response.IsSuccessStatusCode)
throw new Exception($"{method} {path}: {json?["error"]?["code"]} {json?["error"]?["message"]}");
return json;
}
static List<string> ParseCsv(string text)
{
var cells = new List<string>();
var cell = new System.Text.StringBuilder();
var quoted = false;
for (var i = 0; i < text.Length; i++)
{
var ch = text[i];
if (quoted && ch == '"' && i + 1 < text.Length && text[i + 1] == '"') { cell.Append('"'); i++; continue; }
if (ch == '"') { quoted = !quoted; continue; }
if (ch == ',' && !quoted) { cells.Add(cell.ToString()); cell.Clear(); continue; }
cell.Append(ch);
}
cells.Add(cell.ToString());
return cells;
}
Node.js
/* global process, console, fetch */
// Prove the key, then land a sheet of stock as one sync. Node 20+, no dependencies.
//
// FREEPORT_KEY=fp_test_... node sync-sheet.mjs stock-sheet-template.csv
import { readFile } from 'node:fs/promises';
import { setTimeout as sleep } from 'node:timers/promises';
const BASE = process.env.FREEPORT_API ?? 'https://sandbox-api.freeport.gg/v1';
const KEY = process.env.FREEPORT_KEY ?? fail('set FREEPORT_KEY to your fp_test_ or fp_live_ key');
const sheet = process.argv[2] ?? 'stock-sheet-template.csv';
async function api(method, path, body, headers = {}) {
const response = await fetch(`${BASE}${path}`, {
method,
headers: { authorization: `Bearer ${KEY}`, 'content-type': 'application/json', ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await response.json();
if (!response.ok)
throw new Error(`${method} ${path}: ${json.error?.code} ${json.error?.message}`);
return json;
}
// 1. Who am I?
console.log(await api('GET', '/ping'));
// 2. The sheet, one JSON line per row. Tiers are "min_qty:price;min_qty:price".
const [header, ...rows] = (await readFile(sheet, 'utf8')).trim().split(/\r?\n/).map(parseCsvLine);
const numbers = new Set([
'price_per_qty',
'min_qty',
'max_qty',
'stock_qty',
'low_stock_alert_qty',
'delivery_eta_minutes',
]);
const lines = rows.map((cells) => {
const line = {};
header.forEach((key, i) => {
const value = cells[i];
if (!value || key === 'tiers') return;
line[key] = numbers.has(key) ? Number(value) : value;
});
const tiers = cells[header.indexOf('tiers')];
if (tiers)
line.tiers = tiers
.split(';')
.map((rung) => ({ min_qty: Number(rung.split(':')[0]), price: rung.split(':')[1] }));
return line;
});
// 3. Open, stage (up to a thousand a call), commit with an idempotency key.
const sync = await api('POST', '/stock/syncs', { mode: 'replace' });
for (let i = 0; i < lines.length; i += 1000) {
const staged = await api('PUT', `/stock/syncs/${sync.id}/lines`, {
lines: lines.slice(i, i + 1000),
});
console.log(
`staged ${staged.results.filter((r) => r.status === 'staged').length}, refused ${staged.results.filter((r) => r.status !== 'staged').length}`,
);
}
await api('POST', `/stock/syncs/${sync.id}/commit`, undefined, {
'idempotency-key': `commit-${sync.id}`,
});
// 4. The worker applies it; poll the sync, not the listings.
for (;;) {
const state = await api('GET', `/stock/syncs/${sync.id}`);
if (state.status !== 'committing') {
console.log(state.summary);
break;
}
await sleep(1000);
}
// 5. Any line that failed says why.
const failed = await api('GET', `/stock/syncs/${sync.id}/lines?action=failed`);
for (const line of failed.data) console.log(line.external_id, line.error);
function parseCsvLine(text) {
const cells = [];
let cell = '';
let quoted = false;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (quoted && ch === '"' && text[i + 1] === '"') {
cell += '"';
i += 1;
continue;
}
if (ch === '"') {
quoted = !quoted;
continue;
}
if (ch === ',' && !quoted) {
cells.push(cell);
cell = '';
continue;
}
cell += ch;
}
cells.push(cell);
return cells;
}
function fail(message) {
console.error(message);
process.exit(1);
}
PowerShell
# Prove the key, then land a sheet of stock as one sync. Windows PowerShell 5.1 or later.
#
# $env:FREEPORT_KEY = 'fp_test_...'
# .\sync-sheet.ps1 -Sheet stock-sheet-template.csv
param([string]$Sheet = 'stock-sheet-template.csv')
$ErrorActionPreference = 'Stop'
$base = if ($env:FREEPORT_API) { $env:FREEPORT_API } else { 'https://sandbox-api.freeport.gg/v1' }
if (-not $env:FREEPORT_KEY) { throw 'set FREEPORT_KEY to your fp_test_ or fp_live_ key' }
$headers = @{ Authorization = "Bearer $env:FREEPORT_KEY" }
$numbers = 'price_per_qty', 'min_qty', 'max_qty', 'stock_qty', 'low_stock_alert_qty', 'delivery_eta_minutes'
function Invoke-Freeport {
param([string]$Method, [string]$Path, $Body, [hashtable]$Extra = @{})
$params = @{ Method = $Method; Uri = "$base$Path"; Headers = ($headers + $Extra); ContentType = 'application/json' }
if ($null -ne $Body) { $params.Body = ($Body | ConvertTo-Json -Depth 10 -Compress) }
try { return Invoke-RestMethod @params }
catch {
$problem = $_.ErrorDetails.Message | ConvertFrom-Json
throw "$Method $Path`: $($problem.error.code) $($problem.error.message)"
}
}
# 1. Who am I?
Invoke-Freeport GET '/ping' | ConvertTo-Json -Depth 5
# 2. The sheet, one line per row. Tiers are "min_qty:price;min_qty:price".
$lines = foreach ($row in Import-Csv $Sheet) {
$line = @{}
foreach ($property in $row.PSObject.Properties) {
if ([string]::IsNullOrEmpty($property.Value) -or $property.Name -eq 'tiers') { continue }
$line[$property.Name] = if ($numbers -contains $property.Name) { [int]$property.Value } else { $property.Value }
}
if ($row.tiers) {
$line.tiers = @($row.tiers -split ';' | ForEach-Object { $parts = $_ -split ':'; @{ min_qty = [int]$parts[0]; price = $parts[1] } })
}
$line
}
# 3. Open, stage (up to a thousand a call), commit with an idempotency key.
$sync = Invoke-Freeport POST '/stock/syncs' @{ mode = 'replace' }
for ($start = 0; $start -lt $lines.Count; $start += 1000) {
$chunk = @($lines[$start..([Math]::Min($start + 999, $lines.Count - 1))])
$staged = Invoke-Freeport PUT "/stock/syncs/$($sync.id)/lines" @{ lines = $chunk }
$refused = @($staged.results | Where-Object status -ne 'staged')
Write-Host "staged $($staged.results.Count - $refused.Count), refused $($refused.Count)"
}
Invoke-Freeport POST "/stock/syncs/$($sync.id)/commit" $null @{ 'Idempotency-Key' = "commit-$($sync.id)" } | Out-Null
# 4. The worker applies it; poll the sync, not the listings.
do {
Start-Sleep -Seconds 1
$state = Invoke-Freeport GET "/stock/syncs/$($sync.id)"
} while ($state.status -eq 'committing')
$state.summary | ConvertTo-Json
# 5. Any line that failed says why.
(Invoke-Freeport GET "/stock/syncs/$($sync.id)/lines?action=failed").data |
ForEach-Object { Write-Host "$($_.external_id): $($_.error.code) $($_.error.message)" }
Python
"""Prove the key, then land a sheet of stock as one sync. Python 3.10+, standard library only.
FREEPORT_KEY=fp_test_... python sync-sheet.py stock-sheet-template.csv
"""
import csv
import json
import os
import sys
import time
import urllib.error
import urllib.request
BASE = os.environ.get("FREEPORT_API", "https://sandbox-api.freeport.gg/v1")
KEY = os.environ.get("FREEPORT_KEY") or sys.exit("set FREEPORT_KEY to your fp_test_ or fp_live_ key")
SHEET = sys.argv[1] if len(sys.argv) > 1 else "stock-sheet-template.csv"
NUMBERS = {"price_per_qty", "min_qty", "max_qty", "stock_qty", "low_stock_alert_qty", "delivery_eta_minutes"}
def api(method, path, body=None, headers=None):
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request(BASE + path, data=data, method=method)
request.add_header("Authorization", f"Bearer {KEY}")
request.add_header("Content-Type", "application/json")
for name, value in (headers or {}).items():
request.add_header(name, value)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
except urllib.error.HTTPError as error:
problem = json.load(error).get("error", {})
sys.exit(f"{method} {path}: {problem.get('code')} {problem.get('message')}")
# 1. Who am I?
print(api("GET", "/ping"))
# 2. The sheet, one line per row. Tiers are "min_qty:price;min_qty:price".
lines = []
with open(SHEET, newline="", encoding="utf-8") as handle:
for row in csv.DictReader(handle):
line = {k: (int(v) if k in NUMBERS else v) for k, v in row.items() if v and k != "tiers"}
if row.get("tiers"):
line["tiers"] = [
{"min_qty": int(qty), "price": price}
for qty, price in (rung.split(":") for rung in row["tiers"].split(";"))
]
lines.append(line)
# 3. Open, stage (up to a thousand a call), commit with an idempotency key.
sync = api("POST", "/stock/syncs", {"mode": "replace"})
for start in range(0, len(lines), 1000):
staged = api("PUT", f"/stock/syncs/{sync['id']}/lines", {"lines": lines[start : start + 1000]})
refused = [r for r in staged["results"] if r["status"] != "staged"]
print(f"staged {len(staged['results']) - len(refused)}, refused {len(refused)}")
api("POST", f"/stock/syncs/{sync['id']}/commit", headers={"Idempotency-Key": f"commit-{sync['id']}"})
# 4. The worker applies it; poll the sync, not the listings.
while True:
state = api("GET", f"/stock/syncs/{sync['id']}")
if state["status"] != "committing":
print(state.get("summary"))
break
time.sleep(1)
# 5. Any line that failed says why.
for line in api("GET", f"/stock/syncs/{sync['id']}/lines?action=failed")["data"]:
print(line["external_id"], line["error"])
curl
#!/usr/bin/env bash
# Prove the key, then land a sheet of stock as one sync. Needs curl and jq.
#
# FREEPORT_KEY=fp_test_... ./sync-sheet.sh stock-sheet-template.csv
set -euo pipefail
BASE="${FREEPORT_API:-https://sandbox-api.freeport.gg/v1}"
KEY="${FREEPORT_KEY:?set FREEPORT_KEY to your fp_test_ or fp_live_ key}"
SHEET="${1:-stock-sheet-template.csv}"
auth=(-H "Authorization: Bearer $KEY" -H "Content-Type: application/json")
# 1. Who am I? The answer names the key, its scopes and its environment.
curl -sS "$BASE/ping" "${auth[@]}" | jq .
# 2. Open a sync. Replace mode pauses every line the sheet leaves out.
sync=$(curl -sS -X POST "$BASE/stock/syncs" "${auth[@]}" -d '{"mode":"replace"}' | jq -r .id)
echo "sync $sync"
# 3. Stage the sheet: one JSON line per CSV row, up to a thousand a call.
# Tiers in the sheet are "min_qty:price;min_qty:price".
lines=$(python3 - "$SHEET" <<'PY'
import csv, json, sys
rows = []
for row in csv.DictReader(open(sys.argv[1], newline='', encoding='utf-8')):
line = {k: v for k, v in row.items() if v not in ('', None) and k != 'tiers'}
for key in ('price_per_qty', 'min_qty', 'max_qty', 'stock_qty', 'low_stock_alert_qty', 'delivery_eta_minutes'):
if key in line: line[key] = int(line[key])
if row.get('tiers'):
line['tiers'] = [{'min_qty': int(q), 'price': p} for q, p in (t.split(':') for t in row['tiers'].split(';'))]
rows.append(line)
print(json.dumps({'lines': rows}))
PY
)
curl -sS -X PUT "$BASE/stock/syncs/$sync/lines" "${auth[@]}" -d "$lines" | jq '.results | group_by(.status) | map({(.[0].status): length}) | add'
# 4. Commit, then wait for the worker. An Idempotency-Key makes the commit safe to retry.
curl -sS -X POST "$BASE/stock/syncs/$sync/commit" "${auth[@]}" -H "Idempotency-Key: commit-$sync" >/dev/null
for _ in $(seq 1 60); do
status=$(curl -sS "$BASE/stock/syncs/$sync" "${auth[@]}")
state=$(echo "$status" | jq -r .status)
if [ "$state" != "committing" ]; then echo "$status" | jq .; break; fi
sleep 1
done
# 5. Any line that failed says why.
curl -sS "$BASE/stock/syncs/$sync/lines?action=failed" "${auth[@]}" | jq '.data[] | {external_id, error}'