Skip to main content
reference 5 min read

Server-to-server integration (Client Credentials)

The client-credentials flow — your own backend syncing data for your own we-fly account.

Use this flow when you control both ends: your own backend pushing or pulling data for your own we-fly account. The classic example is a flight-tracker (XContest, WeGlide, SkyLines) that imports IGC files into we-fly on a schedule.

For "let me act on behalf of any user who installs my app", see User authorization instead — client credentials cannot impersonate arbitrary users.

Registering the application

  1. Sign in to https://we-fly.cloud as the pilot whose data the integration will manage.
  2. Open https://we-fly.cloud/developer.
  3. Click New application and fill out:
    • Name — shown in audit logs ("XContest backfill").
    • Description — one sentence; appears in the consent screen (irrelevant for this flow but recommended for clarity).
    • Client typeconfidential.
    • Redirect URIs — required field, even though client credentials doesn't use them. Use a placeholder like https://example.invalid/unused.
    • Grant types — tick client_credentials.
    • Allowed scopes — pick the minimum your integration needs.
  4. Copy the Client ID (wf_app_…) and Client secret (wf_secret_…). The secret is shown once; store it in a dedicated secret store on your side.

New applications start in pending status. While pending you can only mint tokens for read-only scopes against your own account. Ask an admin to promote the app to approved once you've validated the integration locally.

Exchanging credentials for an access token

curl -X POST https://we-fly.cloud/api/oauth/token \
  -u "${CLIENT_ID}:${CLIENT_SECRET}" \
  -H "Accept: application/json" \
  -d "grant_type=client_credentials" \
  -d "scope=flights:write"

Response (example):

{
  "access_token": "wf_at_3b8…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "flights:write"
}

Notes:

  • Access tokens last 1 hour. There is no refresh token on this flow — the access token is cheap to re-mint with the same client_id / client_secret.
  • scope is optional. Omitted → the token carries every scope your app is registered for. Supplying a narrower scope reduces blast radius if the token leaks.
  • Authentication may also be passed as form fields if your HTTP client cannot do Basic auth (-d "client_id=…" -d "client_secret=…"). HTTP Basic is recommended.

Calling the API

curl -X POST https://we-fly.cloud/api/v1/flights/upload \
  -H "Authorization: Bearer ${ACCESS_TOKEN}" \
  -H "Content-Type: application/octet-stream" \
  -H "X-Filename: 2026-05-15-XCT-001.igc" \
  --data-binary "@2026-05-15-XCT-001.igc"

Or with multipart:

curl -X POST https://we-fly.cloud/api/v1/flights/upload \
  -H "Authorization: Bearer ${ACCESS_TOKEN}" \
  -F "file=@2026-05-15-XCT-001.igc"

A successful upload returns 201 Created with:

{
  "data": {
    "hash": "f4b9…",
    "fileName": "2026-05-15-XCT-001.igc",
    "isNew": true
  }
}

we-fly de-duplicates by GPS-fix hash. Re-uploading the same flight is idempotent and returns 200 OK with isNew: false.

Reading data

curl https://we-fly.cloud/api/v1/flights \
  -H "Authorization: Bearer ${ACCESS_TOKEN}"
{
  "data": [
    {
      "id": "65a4f1…",
      "flight_date": "2026-05-15",
      "date_created": "2026-05-15T18:42:11.000Z",
      "original_file_name": "2026-05-15-XCT-001.igc",
      "is_tandem": false,
      "launch_type": "Self",
      "season": 2026,
      "scoring": {
        "distance": 87.3,
        "score": 130.95,
        "type": "FAITriangle"
      },
      "flightInfo": {
        "takeoff": {
          "id": "65aa…",
          "lat": 46.123,
          "lng": 6.456,
          "name": "Plaine Joux",
          "country": "FR"
        },
        "metadata": {
          "glider-type": "Ozone Zeolite GT"
        },
        "track": {
          "duration": 14820,
          "distance": 105230
        }
      }
    }
  ],
  "pagination": {
    "page": 1,
    "pageSize": 25,
    "totalCount": 142,
    "totalPages": 6
  }
}

Query parameters:

ParameterDefaultNotes
page11-indexed
pageSize25Maximum 100

The full schema of data[] is in the endpoint reference.

Reference implementation (Node.js, fetch)

import { readFile } from "node:fs/promises";

const TOKEN_URL = "https://we-fly.cloud/api/oauth/token";
const API_BASE  = "https://we-fly.cloud/api/v1";

let cachedToken;            // { value, expiresAt }
async function getToken() {
  if (cachedToken && cachedToken.expiresAt > Date.now() + 60_000) {
    return cachedToken.value;
  }
  const basic = Buffer.from(
    `${process.env.WEFLY_CLIENT_ID}:${process.env.WEFLY_CLIENT_SECRET}`,
  ).toString("base64");
  const res = await fetch(TOKEN_URL, {
    method: "POST",
    headers: {
      Authorization: `Basic ${basic}`,
      "Content-Type": "application/x-www-form-urlencoded",
      Accept: "application/json",
      "User-Agent": "MyIntegration/1.0",
    },
    body: new URLSearchParams({
      grant_type: "client_credentials",
      scope: "flights:write flights:read",
    }),
  });
  if (!res.ok) throw new Error(`token exchange failed: ${res.status}`);
  const json = await res.json();
  cachedToken = {
    value: json.access_token,
    expiresAt: Date.now() + json.expires_in * 1000,
  };
  return cachedToken.value;
}

export async function uploadIgc(path) {
  const igc = await readFile(path);
  const res = await fetch(`${API_BASE}/flights/upload`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${await getToken()}`,
      "Content-Type": "application/octet-stream",
      "X-Filename": path.split("/").pop(),
      "User-Agent": "MyIntegration/1.0",
    },
    body: igc,
  });
  if (res.status === 429) {
    const retry = Number(res.headers.get("Retry-After") ?? 60);
    await new Promise(r => setTimeout(r, retry * 1000));
    return uploadIgc(path);
  }
  if (!res.ok) {
    const error = await res.json().catch(() => ({}));
    throw new Error(`upload failed: ${res.status} ${JSON.stringify(error)}`);
  }
  return res.json();
}

Reference implementation (Python, requests)

import os
import time
import requests
from base64 import b64encode

TOKEN_URL = "https://we-fly.cloud/api/oauth/token"
API_BASE  = "https://we-fly.cloud/api/v1"
UA        = "MyIntegration/1.0"

_token = {"value": None, "expires_at": 0}

def get_token():
    if _token["value"] and _token["expires_at"] > time.time() + 60:
        return _token["value"]
    basic = b64encode(
        f"{os.environ['WEFLY_CLIENT_ID']}:{os.environ['WEFLY_CLIENT_SECRET']}".encode()
    ).decode()
    r = requests.post(
        TOKEN_URL,
        headers={
            "Authorization": f"Basic {basic}",
            "Accept": "application/json",
            "User-Agent": UA,
        },
        data={"grant_type": "client_credentials", "scope": "flights:write flights:read"},
        timeout=15,
    )
    r.raise_for_status()
    body = r.json()
    _token["value"] = body["access_token"]
    _token["expires_at"] = time.time() + body["expires_in"]
    return _token["value"]


def upload_igc(path: str) -> dict:
    with open(path, "rb") as fh:
        data = fh.read()
    for _ in range(3):
        r = requests.post(
            f"{API_BASE}/flights/upload",
            headers={
                "Authorization": f"Bearer {get_token()}",
                "Content-Type": "application/octet-stream",
                "X-Filename": os.path.basename(path),
                "User-Agent": UA,
            },
            data=data,
            timeout=60,
        )
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", "60")))
            continue
        r.raise_for_status()
        return r.json()
    raise RuntimeError("upload retries exhausted")

What you must implement on your side

  • Token caching. Re-use the access token until expires_in elapses. Do not mint a fresh token per request.
  • Backoff on 429. Honour Retry-After (seconds).
  • Secret rotation. Store the client_secret in a secret manager. To rotate, visit /developer, click Rotate secret, deploy the new value, then immediately invalidate any cached token (it was minted under the old secret; refreshing is fine, but new tokens will need the new credentials).
  • TLS verification. Don't disable certificate checks. we-fly.cloud uses Let's Encrypt with strict OCSP.
  • User-Agent. See overview.