Skip to main content

Partner API

The Partner API lets a partner run the whole commercial flow from their own systems: register a deal the moment an opportunity is created, bring a customer onto the platform when it is won, and reconcile margin statements against their own ledger.

For what the flow means commercially — protection windows, conflicts, how margin is calculated — read the Partner programme guide first. This page is the mechanics.

Base URL:

https://frem.sh/_app/api/v1
Note the /_app prefix. frem.sh/api/v1/... is Forgejo’s own API, which is a different surface with different tokens. A partner call sent there will not fail in an obvious way — it will simply be answered by Forgejo.

Authentication

Partner endpoints take a fremforge personal access token as a bearer token:

curl -H "Authorization: Bearer $FF_TOKEN" \
  https://frem.sh/_app/api/v1/partner/me

Two scopes govern access:

ScopeGrants
partner:read/partner/me, /partner/customers, /partner/deals (GET), /partner/statements
partner:writeRegistering deals, requesting enterprise terms, creating customers

A read-only integration — a dashboard, a reconciliation job — should hold partner:read alone.

Getting a token

Mint one from the partner portal under Tokens, or from an existing token with the tokens:manage scope:

curl -X POST -H "Authorization: Bearer $FF_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"crm-integration","scopes":["partner:read","partner:write"],"ttl_days":90}' \
  https://frem.sh/_app/api/v1/auth/tokens
{ "token": "ffp_...", "id": "0192...", "expires_at": "2026-11-14T09:12:00Z" }

ttl_days defaults to 30 and is capped at 90, so a long-running integration needs a rotation plan rather than a token it can set and forget. The token value is shown once. See Token management for rotation and revocation.

Two ways a call can be refused

These mean different things and are worth distinguishing in your error handling:

  • 403 not_a_partner — the token is valid, but its owner is not a partner user. Usually the wrong token.
  • 403 insufficient-scope — the right identity, missing the scope. Re-mint with partner:write.

Neither spelling is a typo on this page: not_a_partner uses an underscore, insufficient-scope a hyphen. Match on the exact string.

Partner data is deliberately cross-tenant: these endpoints are not scoped to an organisation, and a partner’s own tenant is excluded from the tenants they can otherwise administer.

GET /partner/me

Who you are acting as, and whether deal registration is currently paused for you.

curl -H "Authorization: Bearer $FF_TOKEN" \
  https://frem.sh/_app/api/v1/partner/me
{
  "partner_id": "3f2a...",
  "partner_name": "Nordic Resell ApS",
  "username": "anna",
  "agreement_status": "active"
}

agreement_status of paused still authenticates and still serves every read. Pausing is an abuse control on registration, not a lockout — do not treat it as an auth failure.

GET /partner/customers

The customers currently assigned to you.

{ "customers": [ { "tenant_id": "9c1e...", "org_slug": "acme" } ] }

Margin counts from the assignment date, so a customer assigned late earns nothing for the months before it.

GET /partner/deals

Every deal you have registered, newest first.

{
  "deals": [
    {
      "id": "7b40...",
      "end_customer": "Acme A/S",
      "country": "DK",
      "status": "approved",
      "conflict_state": "clear",
      "tenant_id": null,
      "protection_expires_at": "2026-11-14T00:00:00Z"
    }
  ]
}

protection_expires_at is the end of the 90-day window the registration bought. We email you 14 days and 3 days before it lapses.

conflict_state of flagged means another partner registered the same company. We resolve those on evidence of sales activity, never on who was first, and we do not disclose the other partner.

POST /partner/deals

Register a prospect and start the protection window. Requires partner:write.

curl -X POST -H "Authorization: Bearer $FF_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "end_customer_legal_name": "Acme A/S",
        "end_customer_country": "DK",
        "end_customer_registration_number": "12345678",
        "end_customer_contact_email": "cto@acme.example",
        "estimated_seats": 25,
        "expected_close_date": "2026-11-01",
        "sales_activity_evidence": "Demo held 2026-08-12; POC agreed with their CTO."
      }' \
  https://frem.sh/_app/api/v1/partner/deals

Required: end_customer_legal_name, end_customer_country (ISO 3166-1 alpha-2, uppercase).

{ "id": "7b40...", "flagged": false, "pinned_price_list_version": "2026-06" }
StatusMeaning
201Registered
400Refused — the body names which check failed
429Daily registration cap reached
flagged: true is a 201, not an error. The deal is registered; another partner has registered the same company and we will resolve it. Do not retry — retrying spends your daily allowance and changes nothing.

Fill in sales_activity_evidence properly. It is the evidence a conflict is decided on.

POST /partner/deals/{dealId}/enterprise-request

Ask for negotiated seat pricing before creating the customer. Requires partner:write.

curl -X POST -H "Authorization: Bearer $FF_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"note":"Needs 400 seats and annual invoicing; competing against a US vendor."}' \
  https://frem.sh/_app/api/v1/partner/deals/7b40.../enterprise-request
{ "ok": true, "recorded": true }

Always answers 200. recorded: false means the request did not apply — deliberately without distinguishing “already asked” from “not your deal”.

First-write-wins: asking twice does not move the recorded date or replace the note. Accepted only while the deal is approved or won and no customer exists yet; afterwards the tenant is real and converting it is an operator action.

POST /partner/deals/{dealId}/customer

Bring the customer onto the platform for a deal you have won. Requires partner:write.

curl -X POST -H "Authorization: Bearer $FF_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "org_slug": "acme",
        "display_name": "Acme A/S",
        "admin_email": "cto@acme.example"
      }' \
  https://frem.sh/_app/api/v1/partner/deals/7b40.../customer

Required: org_slug (lowercase DNS-safe, 3–30 characters — it becomes frem.sh/<slug> and cannot be changed), display_name, admin_email.

This creates the organisation, invites the administrator by email, and assigns the customer to you so margin counts from their first invoice.

The customer starts on a trial. The plan is deliberately not a parameter — choosing it would be granting commercial terms. The response carries billing_setup_url, where the customer adds a payment method and converts; the same link is emailed to them.

Refused when the deal is not yours, is not approved or won, already has a customer, or when registration is paused for you. 429 when the daily customer-creation cap is reached.

GET /partner/statements

Your margin statements, newest period first.

{
  "statements": [
    {
      "id": "aa19...",
      "period_start": "2026-07-01",
      "period_end": "2026-07-31",
      "currency": "EUR",
      "margin_eur": "1284.50",
      "total_invoiceable_eur": "1284.50",
      "sent_at": "2026-08-01T06:00:00Z",
      "partner_invoice_ref": "INV-2026-114",
      "paid_at": null
    }
  ]
}

Statements are built on the 1st for the previous calendar month.

The money fields are decimal strings, returned exactly as stored. Do not parse them as floats and do not re-round them, or your reconciliation will disagree with the statement it came from.

Settlement is ordered sent_atpartner_invoice_refpaid_at. Nothing is paid before a partner invoice reference exists, because your invoice is the document the payment is against. Payment is 30 days from receipt of it.

A worked flow

Registering from a CRM the moment an opportunity is created, then creating the customer when it is won:

BASE=https://frem.sh/_app/api/v1
AUTH="Authorization: Bearer $FF_TOKEN"

# 1. Register as soon as the opportunity exists — this is what starts protection.
DEAL=$(curl -sSf -X POST -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"end_customer_legal_name":"Acme A/S","end_customer_country":"DK",
       "sales_activity_evidence":"Discovery call 2026-08-12"}' \
  "$BASE/partner/deals")
DEAL_ID=$(printf '%s' "$DEAL" | jq -r .id)

# `flagged` is informational, not a failure — carry on, do not retry.
printf '%s' "$DEAL" | jq -r 'if .flagged then "conflict registered; we will resolve it" else "protected" end'

# 2. When it is won, create the customer.
curl -sSf -X POST -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"org_slug":"acme","display_name":"Acme A/S","admin_email":"cto@acme.example"}' \
  "$BASE/partner/deals/$DEAL_ID/customer" | jq -r .billing_setup_url

# 3. Reconcile, as strings.
curl -sSf -H "$AUTH" "$BASE/partner/statements" \
  | jq -r '.statements[] | "\(.period_start) \(.margin_eur) \(.currency) paid=\(.paid_at // "no")"'

Errors

Partner endpoints use the platform-wide application/problem+json shape. The type URL resolves to a page describing the specific failure — see API errors.

The full specification

These endpoints are part of the published OpenAPI document, under the Partner tag:

The spec is generated from the running service and is authoritative where this page and it disagree.