Browse the documentation

Tools, OpenAPI and code samples

GET /v1/openapi.json

No permission, no key. It is the only open route of the API.

DocumentDetail
ContentsOpenAPI 3.1: every route, parameter, body, response and error, plus x-scopes (the permission catalogue) and x-rate-limits
Generationreflected from the controllers when the API starts. It always describes the code serving it
CacheCache-Control: public, max-age=300, with an ETag. An If-None-Match answers 304

The document's servers entry is /, a relative URL. After an import, set the base to https://api.freshperf.fr: without it your client will build paths relative to the current host.

UseHow
Browse itfreshperf.fr/api-reference. Search, samples, try-it console. Calls go from your browser to api.freshperf.fr
Import itPostman, Insomnia, Bruno and Hoppscotch take the URL directly. Set the bearer token at collection level
Generate a clientopenapi-generator, openapi-typescript, oapi-codegen, Kiota

bash

export FRESHPERF_KEY="fpk_9al41uPRzeglnYvHa3YfvsK86OfQwx6BmC2EKmI0Vhw"
api() { curl -sS -H "Authorization: Bearer $FRESHPERF_KEY" "$@"; }

# Every service with its status
api https://api.freshperf.fr/v1/services | jq -r '.data[] | "\(.code)\t\(.status)\t\(.name)"'

# Restart one
api -X POST -H "Content-Type: application/json" -d '{"action":"restart"}' \
    https://api.freshperf.fr/v1/services/SRV-TJUAQ1-1951/power

# Download the last invoice, if there is one
code=$(api "https://api.freshperf.fr/v1/invoices?limit=1" | jq -r '.data[0].code // empty')
[ -n "$code" ] && api -o "$code.pdf" "https://api.freshperf.fr/v1/invoices/$code/pdf"

Python

import os, uuid, requests

API = "https://api.freshperf.fr/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['FRESHPERF_KEY']}"

def get(path, **params):
    r = session.get(f"{API}{path}", params=params)
    if not r.ok:
        err = r.json().get("error", {})
        raise RuntimeError(f"{r.status_code} {err.get('code')}: {err.get('message')} "
                           f"(request {r.headers.get('X-Request-Id')})")
    return r.json()

def each(path, limit=100, **params):
    """Walks a cursor list. Not suitable for /account/notifications."""
    cursor = None
    while True:
        page = get(path, cursor=cursor, limit=limit, **params)
        yield from page["data"]
        cursor = page["pagination"]["nextCursor"]
        if not cursor:
            break

for service in each("/services", status="ACTIVE"):
    print(service["code"], service["nextBillingAt"])

# Notifications page by page, with a ceiling of 50
page1 = get("/account/notifications", page=1, limit=50)
print(page1["data"]["total"], len(page1["data"]["items"]))

# Pay an invoice from the balance. The idempotency key is made once and kept
# with the task: it is what makes the retry safe.
key = str(uuid.uuid4())
r = session.post(f"{API}/invoices/INV-202608-0082/pay",
                 json={"method": "balance"},
                 headers={"Idempotency-Key": key})
print(r.status_code, r.json())

Node.js

const API = "https://api.freshperf.fr/v1";

async function call(method, path, body, extra = {}) {
  const res = await fetch(API + path, {
    method,
    headers: {
      Authorization: `Bearer ${process.env.FRESHPERF_KEY}`,
      ...(body ? { "Content-Type": "application/json" } : {}),
      ...extra,
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json().catch(() => ({}));
  if (!res.ok) {
    const e = json.error ?? {};
    throw new Error(`${res.status} ${e.code}: ${e.message} (request ${res.headers.get("x-request-id")})`);
  }
  return json;
}

const { data: me } = await call("GET", "/me");
console.log(me.key.scopes);

const { data: status } = await call("GET", "/services/SRV-TJUAQ1-1951/status");
if (status.status !== "running") {
  await call("POST", "/services/SRV-TJUAQ1-1951/power", { action: "start" });
}

// An order paid from the balance. The values come from the catalogue.
const idempotencyKey = crypto.randomUUID();
const order = await call("POST", "/orders", {
  lines: [{
    product: "vps-1",
    recurrence: "monthly",
    characteristics: {
      os: "debian-13",
      location: "paris",
      authType: "PASSWORD",
      authValue: process.env.VPS_ROOT_PASSWORD,
    },
  }],
  payment: { method: "balance" },
}, { "Idempotency-Key": idempotencyKey });

console.log(order.data.order.orderNumber, order.data.order.serviceCodes);

Habits that save time

  • The key in an environment variable or a secret manager, never in the code.
  • Log the X-Request-Id of every non-2xx response. It is what support will ask for.
  • Page with pagination.nextCursor, except on /account/notifications, which pages by page.
  • Retry 429 after Retry-After, and 5xx with exponential backoff.
  • On the four routes that move money, keep the same idempotency key for every retry of one task. See the table of refusals that release the key in Rate limits and idempotency.
  • On 409 IDEMPOTENCY_IN_PROGRESS, wait Retry-After and replay the same request: the first call will finish and you will get its result.
  • Poll /status every 5 to 10 seconds at most: each call asks the infrastructure live.
  • Re-read the key's log on the first day an integration runs. A refusal sitting there is a forgotten permission or restriction.