Requests and responses
Base URL
https://api.freshperf.fr/v1. Every route in this section is relative to that base, and only HTTPS is served.
Requests
| Request | Rule |
|---|---|
| Body | JSON, with Content-Type: application/json |
| Path parameters | the public identifiers in the table below |
| Filters and pagination | query parameters |
| Unknown fields in a body | ignored |
The API refuses a malformed value rather than silently correcting it:
| Case | Answer |
|---|---|
?limit=abc | 400 VALIDATION_ERROR:limit |
?limit=0, ?limit=500 | 400 VALIDATION_ERROR:limit |
?cursor= unreadable | 400 VALIDATION_ERROR:cursor |
?status= outside the enum | 400 VALIDATION_ERROR:status |
| Body field missing or outside its enum | 400 VALIDATION_ERROR:<field> |
Responses
A success is an envelope:
{ "data": { "code": "SRV-TJUAQ1-1951", "status": "ACTIVE" } }
A list adds pagination:
{ "data": [ { "code": "SRV-TJUAQ1-1951", "status": "ACTIVE" } ], "pagination": { "nextCursor": "MTg4", "limit": 20 } }
| Field | Type | Meaning |
|---|---|---|
pagination.nextCursor | string or null | pass back as ?cursor=. null on the last page |
pagination.limit | integer | the page size actually applied |
201 on a creation, 200 otherwise. The two PDF routes answer the file, not JSON.
Pagination
?limit= runs 1 to 100, 20 by default. Out of range answers 400 VALIDATION_ERROR:limit.
The cursor is opaque and rests on two different mechanics depending on the route. Do not build one yourself.
| Mechanic | Routes | Behaviour |
|---|---|---|
| Last id seen | GET /services, GET /orders, GET /invoices | an insert mid-walk does not shift the pages |
| Offset | GET /balance/transactions, GET /credit-notes, GET /tickets | an insert mid-walk shifts the later pages |
Lists run newest to oldest, except GET /tickets, sorted on the last update.
The exception: notifications
GET /account/notifications follows none of these rules.
| Notifications | Rule |
|---|---|
| Pagination | ?page= (1 by default, minimum 1) and ?limit= (20 by default, maximum 50) |
| Envelope | {"data": {"items": [...], "total": n, "unread": n}}, with no pagination block |
Errors
Every error, on every route, has the same shape:
{ "error": { "code": "API_SCOPE_MISSING:services.power:write", "message": "This API key does not hold the scope this operation needs.", "docs": "https://freshperf.fr/api-reference" } }
codeis a stable marker. Branch your code on it.messageis an English sentence for humans. It can change; do not parse it.
Some markers carry a suffix after a colon:
| Marker | Suffix |
|---|---|
API_SCOPE_MISSING:<scope> | the missing permission |
API_ACTION_NOT_ALLOWED:<action> | the refused provider action |
VALIDATION_ERROR:<field> | the offending field or parameter |
PRODUCT_NOT_FOUND:<shortname>, PRODUCT_NOT_ORDERABLE:<shortname>, RECURRENCE_REQUIRED:<shortname>, RECURRENCE_NOT_OFFERED:<shortname> | the product of the order line |
INVALID_METADATA_KEY:<key>, INVALID_METADATA_VALUE:<key>, MISSING_REQUIRED:<key> | the characteristic of the line |
SERVICE_NOT_FOUND never carries a suffix.
Status classes:
| Status | Meaning |
|---|---|
400 | the request is malformed, or the state does not allow it |
401 | key absent or unknown |
402 | a payment did not go through |
403 | permission, cap or address |
404 | does not exist, or is outside the key's reach |
409 | conflict with the current state, or a provider refusal |
429 | too many calls, or too frequent an action |
500 | on our side |
502 | the infrastructure behind the service failed |
503 | a PDF is not ready yet |
The full list is in Error reference.
Identifiers
| Resource | Field | Shape | Where it comes from |
|---|---|---|---|
| Service | code | SRV- + base36 + - + 4 digits, e.g. SRV-TJUAQ1-1951 | GET /services |
| Order | orderNumber | ORD-YYYYMMDD-NNNN, e.g. ORD-20260828-0001 | GET /orders, or the POST /orders response |
| Invoice | code | INV-YYYYMM-NNNN, e.g. INV-202608-0083 | GET /invoices |
| Credit note | code | AV-YYYYMM-NNNN, e.g. AV-202608-0001 | GET /credit-notes |
| Ticket | ticketNumber | TKT- + base36 + - + 4 digits, e.g. TKT-TKH21B-6604 | GET /tickets, or the POST /tickets response |
| Payment method | id | integer | GET /payment-methods |
| SSH key | id | integer | GET /ssh-keys |
| Backup | backupId | string, the provider's own, e.g. 9b5d3938-cbd9-4946-8357-05badcdf6c33 | GET /services/{code}/backups → result |
| Payment | id | integer | GET /invoices/{code} → payments[] |
Treat them as opaque. Infrastructure identifiers (instanceId, ownerId, vmid, node, cluster) are stripped from every response, at every depth.
Amounts and dates
- Amounts are integer cents:
"totalCents": 1199is 11.99 €. Never a float. currencyis an ISO 4217 code, and can benullon a document with no amount.- Dates are epoch milliseconds, UTC:
"nextBillingAt": 1789437385056. - Catalogue prices and a service's recurring price are excluding tax;
taxCentstravels beside them andtotalCentsincludes both. amountCentson a balance transaction is signed: positive on a credit, negative on a debit.
Headers
What you send:
| Header | When |
|---|---|
Authorization: Bearer fpk_... | always |
Content-Type: application/json | whenever there is a body |
Idempotency-Key | on the four routes that move money (see Rate limits and idempotency) |
What you get back:
| Header | When | Meaning |
|---|---|---|
X-Request-Id | every response | the call's identifier, also in the key's log. Quote it to support |
Cache-Control: no-store | every response except /openapi.json | nothing here is cacheable |
X-RateLimit-Limit, -Remaining, -Reset | authenticated responses | the tightest budget of the current minute. Reset is an epoch timestamp in seconds, not a delay |
Retry-After | 429, 409 IDEMPOTENCY_IN_PROGRESS, 503 PDF_NOT_AVAILABLE | seconds to wait |
WWW-Authenticate | 401 | Bearer realm="freshperf-api" |
Idempotent-Replayed: true | replay | the response comes from an earlier identical call |
GET /v1/openapi.json answers Cache-Control: public, max-age=300 with an ETag, and carries no rate-limit headers.
Browsers
Only the FreshPerf site's origin is allowed through CORS, for the reference's try-it console. An API key does not belong in a web page: call /v1 from your servers.
Compatibility
The API is versioned in the path (/v1). Within v1 we add fields, routes and enum values; we do not remove or rename them.
So write clients that ignore unknown fields and tolerate a new enum value. A breaking change would be a /v2, announced ahead of time.