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/_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/meTwo scopes govern access:
| Scope | Grants |
|---|---|
partner:read | /partner/me, /partner/customers, /partner/deals (GET), /partner/statements |
partner:write | Registering 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 withpartner: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/dealsRequired: end_customer_legal_name, end_customer_country (ISO 3166-1 alpha-2, uppercase).
{ "id": "7b40...", "flagged": false, "pinned_price_list_version": "2026-06" }| Status | Meaning |
|---|---|
201 | Registered |
400 | Refused — the body names which check failed |
429 | Daily 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.../customerRequired: 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.
Settlement is ordered sent_at → partner_invoice_ref → paid_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.