Freeport Seller API

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}'