Ordering through the API
Three calls: read the catalogue, place the order, follow the provisioning. Payment fits inside the order or in a separate call.
GET /catalog/products
Permissions: catalog:read
| Parameter | Type | Required | Values |
|---|---|---|---|
category | string | no | a category shortname, matched exactly |
Private products and retired categories are never listed, and cannot be ordered.
GET /catalog/products/{shortname}
Permissions: catalog:read
{ "data": { "shortname": "vps-1", "category": "vps", "title": { "fr-fr": "VPS Xeon 1", "en-us": "VPS 1" }, "providerType": null, "recurrences": ["monthly", "yearly"], "prices": [ { "recurrence": "monthly", "priceCents": 500 }, { "recurrence": "yearly", "priceCents": 5500 } ], "addons": [ { "shortname": "extra-ram-1gb", "title": { "fr-fr": "+1 Go RAM", "en-us": "+1 Gb RAM" }, "kind": "RESOURCE_DELTA", "maxQuantity": 10, "prices": [ { "recurrence": "monthly", "priceCents": 200 } ] } ], "characteristics": [ { "key": "os", "label": { "fr-fr": "Système", "en-us": "OS" }, "kind": "os-picker", "required": true, "sensitive": false, "defaultValue": null, "options": ["debian-13"], "choices": [ { "value": "debian", "label": { "en-us": "Debian" }, "versions": ["13"] } ], "companionKeys": ["osVersion"] } ] } }
| Field | Type | Notes |
|---|---|---|
shortname | string | send it as product in an order line |
category | string or null | category shortname |
title | object locale → text | not a string |
providerType | string or null | infrastructure type, when it is fixed |
recurrences | array of string | the cycles it is sold in. Open set: read it, do not hard-code it |
prices[] | recurrence + priceCents | price of one cycle, excluding tax, in cents |
addons[] | see below | add-ons attachable to a line |
characteristics[] | see below | the configuration a line must carry |
An add-on (addons[])
| Field | Type | Notes |
|---|---|---|
shortname | string | send it in addons[].shortname |
title | object locale → text | |
kind | string | RESOURCE_DELTA, BACKUP_SLOTS, EXTRA_IPV4, FLAG |
maxQuantity | integer | maximum quantity per line. 0 means unlimited |
prices[] | recurrence + priceCents | excluding tax |
A characteristic (characteristics[])
| Field | Type | Notes |
|---|---|---|
key | string | the key to send under characteristics |
label | object locale → text | |
kind | string | the input type: text-input, select, radio-grid, os-picker, auth-credential, password-input... Open set |
required | boolean | |
sensitive | boolean | the value is a credential: stored encrypted, never echoed back |
defaultValue | string or null | always null when sensitive is true |
options | array of string | the exact values you may send. Empty for free text |
choices | array | the same values with their labels. For an os-picker, one entry per family with its versions |
companionKeys | array of string | the keys the line must carry beside this one |
Two companionKeys cases:
authValue(kind: "auth-credential") needsauthTypebeside it, asPASSWORDorSSH_KEY.os(kind: "os-picker") needsosVersionif you send the family and the version separately. The simpler path is to send the composite value fromoptions(debian-13) and nothing else. It is also whatPOST /services/{code}/reinstalltakes.
POST /orders
Permissions: orders:write, plus those of the funding you pick.
Required header: Idempotency-Key.
| Field | Type | Required | Values | Where it comes from |
|---|---|---|---|---|
lines | array | yes | 1 to 20 lines | - |
lines[].product | string | yes | - | GET /catalog/products → shortname |
lines[].recurrence | string | yes when the product offers any | a value from recurrences | GET /catalog/products → recurrences |
lines[].quantity | integer | no, default 1 | 1 to 100 | - |
lines[].characteristics | object string → string | depends on the product | the keys of characteristics[], values taken from options | GET /catalog/products/{shortname} |
lines[].addons[].shortname | string | yes inside an add-on | - | GET /catalog/products/{shortname} → addons[] |
lines[].addons[].quantity | integer | no, default 1 | 1 to maxQuantity | - |
promoCode | string or null | no | - | - |
customerNote | string or null | no | 500 characters at most | - |
payment | object or null | no, default no payment | see below | - |
characteristics values are always strings, even for a number.
The payment block
| Field | Type | Required | Values |
|---|---|---|---|
method | string | no, default none | none, balance, saved_method |
paymentMethodId | integer | yes when saved_method | GET /payment-methods → id |
method | Extra permissions | Effect |
|---|---|---|
none, or no block | none | the order stays PENDING_PAYMENT. Pay it later |
balance | billing.invoices:pay | the prepaid balance pays. Balance too short: 402 INSUFFICIENT_BALANCE, and no order is created |
saved_method | billing.invoices:pay and billing.saved_methods:charge | the stored method is charged, within the key's cap |
A funding refused before any write leaves no order behind: method not found, method inactive, balance short, cap exceeded.
A zero total, after a 100% discount, settles with no payment and the order goes straight to PAID.
Example
curl -X POST -H "Authorization: Bearer $FRESHPERF_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6f1c0b62-4f4b-4f8e-9b06-0c1d7a5c9e21" \ -d '{ "lines": [{ "product": "vps-1", "recurrence": "monthly", "quantity": 1, "characteristics": { "os": "debian-13", "location": "paris", "authType": "SSH_KEY", "authValue": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... admin@laptop" }, "addons": [{ "shortname": "extra-ram-1gb", "quantity": 2 }] }], "customerNote": "Web server migration", "payment": { "method": "balance" } }' \ https://api.freshperf.fr/v1/orders
{ "data": { "order": { "orderNumber": "ORD-20260828-0001", "status": "PAID", "currency": "EUR", "subtotalCents": 900, "taxCents": 180, "discountCents": 0, "totalCents": 1080, "amountPaidCents": 1080, "remainingDueCents": 0, "promoCode": null, "customerNote": "Web server migration", "lines": [ { "productShortname": "vps-1", "recurrence": "monthly", "quantity": 1, "unitPriceCents": 500, "unitTaxCents": 100, "totalCents": 1080, "addons": [ { "shortname": "extra-ram-1gb", "quantity": 2, "unitPriceCents": 200 } ], "configuration": { "os": "debian-13", "location": "paris" } } ], "serviceCodes": ["SRV-TKH243-7763"], "createdAt": 1787907301139, "paidAt": 1787907315110 }, "promoError": null, "payment": { "status": "CAPTURED", "paymentId": 244, "amountCents": 1080, "invoiceCode": "INV-202608-0084", "balanceCents": 3320 } } }
| Field | Type | Notes |
|---|---|---|
order.status | string | DRAFT, PENDING_PAYMENT, AUTHORIZED, PAID, FULFILLED, CANCELLED, FAILED, REFUNDED |
order.serviceCodes | array of string | the services the order created. Empty until the payment settles and the provisioning finishes |
order.lines[].configuration | object string → string | the configuration as ordered. sensitive values are never in it |
promoError | string or null | set when the promo code could not apply. The order stands at full price |
payment.status | string | NONE when nothing was charged, FREE on a zero total, otherwise a Payment.Status value |
Placing an order through the API never touches the cart open in the dashboard.
Refusals
| Error | Status | When |
|---|---|---|
NO_ITEMS | 400 | lines empty or absent |
TOO_MANY_ITEMS | 400 | more than 20 lines |
PRODUCT_NOT_FOUND:<shortname> | 400 | unknown or private product |
PRODUCT_NOT_ORDERABLE:<shortname> | 400 | category withdrawn from sale |
RECURRENCE_REQUIRED:<shortname> | 400 | the product sells by cycle and recurrence is missing |
RECURRENCE_NOT_OFFERED:<shortname> | 400 | that cycle does not exist for this product |
MISSING_REQUIRED:<key> | 400 | a required characteristic is missing |
INVALID_METADATA_KEY:<key> | 400 | a key the product does not declare |
INVALID_METADATA_VALUE:<key> | 400 | a value outside options |
ADDON_NOT_AVAILABLE:<shortname> | 400 | add-on not offered by this product |
ADDON_MAX_QUANTITY:<shortname> | 400 | quantity beyond maxQuantity |
VALIDATION_ERROR:lines.quantity | 400 | quantity outside 1 to 100 |
VALIDATION_ERROR:customerNote | 400 | beyond 500 characters |
VALIDATION_ERROR:payment.method | 400 | method outside the list |
PAYMENT_METHOD_ID_REQUIRED | 400 | saved_method without paymentMethodId |
PAYMENT_METHOD_NOT_FOUND | 404 | method not on the account |
PAYMENT_METHOD_NOT_ACTIVE | 400 | method expired or blocked |
INSUFFICIENT_BALANCE | 402 | balance too short |
PAYMENT_DENIED | 402 | the bank refused. The order stays payable another way |
API_KEY_SPENDING_CAP_EXCEEDED | 403 | the key's cap does not cover the total |
PAYMENT_IN_PROGRESS | 409 | another payment of the same order is running |
PAYMENT_RECORDED_MISMATCH | 409 | reconciliation anomaly. Contact support |
ORDER_CONCURRENT_UPDATE | 409 | the order changed during the write. Retry |
POST /orders/{orderNumber}/pay
Permissions: billing.invoices:pay, plus billing.saved_methods:charge with saved_method.
Required header: Idempotency-Key.
Pays what is left due on an order left pending.
| Field | Type | Required | Values | Where it comes from |
|---|---|---|---|---|
method | string | yes | balance, saved_method | - |
paymentMethodId | integer | yes when saved_method | > 0 | GET /payment-methods → id |
curl -X POST -H "Authorization: Bearer $FRESHPERF_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: pay-ORD-20260828-0001" \ -d '{"method":"saved_method","paymentMethodId":14}' \ https://api.freshperf.fr/v1/orders/ORD-20260828-0001/pay
Answers the same payment block as POST /orders.
| Error | Status | When |
|---|---|---|
ORDER_NOT_FOUND | 404 | unknown number |
ORDER_NOT_PAYABLE, ORDER_ALREADY_SETTLED, ORDER_ALREADY_PAID | 400 | there is nothing to pay |
AMOUNT_INVALID | 400 | the remaining due is not a valid amount |
Plus the payment refusals from the table above.
GET /orders and GET /orders/{orderNumber}
Permissions: orders:read
| Parameter | Type | Required | Values |
|---|---|---|---|
limit | integer | no, default 20 | 1 to 100 |
cursor | string | no | pagination.nextCursor from the previous page |
The list returns a summary (orderNumber, status, currency, totalCents, amountPaidCents, remainingDueCents, createdAt, paidAt) and omits drafts. The detail returns the whole order, with lines[] and serviceCodes.
Following the provisioning
GET /orders/{orderNumber}:statusmoves toPAID, then toFULFILLEDonce every service is in place.serviceCodesfills with the codes created. It is the link between an order and the services it produced.GET /services/{code}returnsprovisioned: falsewhile the infrastructure comes up, thentrue.
Provisioning runs in the background and takes a few minutes. Poll every 10 to 30 seconds.