Errors
Every API error comes out in the same shape, on any route and on any status.
{ "error": { "type": "authentication_error", "code": "api_key_invalid", "message": "Invalid, revoked or expired API key.", "request_id": "0a8c1f2e-3b4d-4c5f-9a6b-7c8d9e0f1a2b", "doc_url": "https://docs.fatureihoje.com/en/errors#api_key_invalid" }}The fields
Section titled “The fields”| Field | Always there | What it is |
|---|---|---|
type | Yes | The error family. It lets you branch by range without knowing every code |
code | Yes | The exact case. This is what your program decides on |
message | Yes | A sentence for a person to read. It comes in the Accept-Language language |
doc_url | Yes | The address of this page, at the anchor of the code |
request_id | On /public/v1 | The identifier of this call. It is what support asks for |
param | No | Reserved. The contract allows it for a single-field error, and no route emits it today |
errors | No | The list of invalid fields. Only on the 422 of validation |
code is contract, message is not
Section titled “code is contract, message is not”The code is stable and written in English. It does not change value, does not change name and does not change with the language. A code published in the catalog keeps existing even when the API stops emitting it, because an integration that handles it must not break.
The message is the opposite: it is text for a person, and it comes translated according to the Accept-Language header of the request (pt-BR is the default, and en and es are accepted).
The code and status pair is fixed
Section titled “The code and status pair is fixed”Each code has a single HTTP status, and it never varies. validation_failed is always 422; rate_limit_exceeded is always 429. That is why the catalog below publishes both together: if you handle the code, the status follows, and a pair different from what is here will never arrive.
The seven type families:
type | Range | When |
|---|---|---|
invalid_request_error | 400, 404, 413, 415 | The request is wrong, or what it asks for does not exist |
authentication_error | 401 | The key was not accepted |
permission_error | 403 | The key was accepted, but this action is not allowed |
conflict_error | 409 | Conflict with the current state |
validation_error | 422 | Invalid field |
rate_limit_error | 429 | Request budget exhausted |
api_error | 500, 503 | The problem is ours |
errors, on the 422
Section titled “errors, on the 422”When one or more fields are invalid, the body carries the list, field by field:
{ "error": { "type": "validation_error", "code": "validation_failed", "message": "One or more fields are invalid.", "errors": [ { "param": "email", "code": "invalid", "message": "Invalid e-mail" }, { "param": "phone", "code": "invalid", "message": "Invalid phone" } ], "request_id": "6f12b0d4-8e33-4a91-b2c7-5d0a7e93f118", "doc_url": "https://docs.fatureihoje.com/en/errors#validation_failed" }}param is the field name as the API receives it. message is text for a person; what your program uses is param.
This param is the one inside errors, and it is the only one you will see today. The top-level param, next to code, is allowed by the contract but no route emits it: always read the errors list.
request_id
Section titled “request_id”Every /public/v1 response, with or without an error, carries the Request-Id header. In the error body, the same value shows up in request_id.
Store that value in your log whenever a call fails. It is how support finds the call, and it is the only path when the error is internal.
A path outside /public/v1 answers 404 before the call gets an identifier, so that 404 comes without request_id. If you got an error with no request_id, check the path first.
doc_url
Section titled “doc_url”The doc_url points to this page, already at the anchor of the code:
https://docs.fatureihoje.com/en/errors#api_key_invalidIn Portuguese the address drops the language prefix: docs.fatureihoje.com/errors#.... You can record that address in your error log: it is short, permanent, and goes straight to the explanation of the code.
Reading the error in your code
Section titled “Reading the error in your code”The example calls GET /public/v1/me and handles both outcomes: the good response and the error body. It works for any route, because the error shape is the same everywhere.
curl -sS -i https://api.fatureihoje.com/public/v1/me \ -H "Authorization: Bearer $FH_API_KEY" \ -H "Accept-Language: en"-i prints the headers before the body, so Request-Id shows up next to the error.
const res = await fetch('https://api.fatureihoje.com/public/v1/me', { headers: { Authorization: `Bearer ${process.env.FH_API_KEY}`, 'Accept-Language': 'en', },});
const body = await res.json();
if (!res.ok) { const { code, type, message, request_id } = body.error; console.error(`${res.status} ${type} ${code}: ${message}`); console.error('request_id', request_id ?? res.headers.get('Request-Id')); if (code === 'rate_limit_exceeded') { console.error('wait', res.headers.get('Retry-After'), 'seconds'); } process.exit(1);}
console.log(body.organization.name);Run it with node error.mjs. Node.js 18 or newer.
<?php
$headers = [];$ch = curl_init('https://api.fatureihoje.com/public/v1/me');curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('FH_API_KEY'), 'Accept-Language: en', ], CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) { $parts = explode(':', $line, 2); if (count($parts) === 2) { $headers[strtolower(trim($parts[0]))] = trim($parts[1]); } return strlen($line); },]);
$raw = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
// curl_exec returns false when the connection never happened, and throws nothing.if ($raw === false) { fwrite(STDERR, 'network failure: ' . curl_error($ch) . PHP_EOL); exit(1);}
$body = json_decode($raw, true);
if ($status >= 400) { $error = $body['error']; fwrite(STDERR, $status . ' ' . $error['type'] . ' ' . $error['code'] . ': ' . $error['message'] . PHP_EOL); fwrite(STDERR, 'request_id ' . ($error['request_id'] ?? $headers['request-id'] ?? '') . PHP_EOL); if ($error['code'] === 'rate_limit_exceeded') { fwrite(STDERR, 'wait ' . ($headers['retry-after'] ?? '') . ' seconds' . PHP_EOL); } exit(1);}
echo $body['organization']['name'], PHP_EOL;Run it with php error.php. It needs the curl and json extensions. No curl_close: since PHP 8 the handle is freed on its own, and in 8.5 the function became deprecated.
import jsonimport osimport sysimport urllib.errorimport urllib.request
request = urllib.request.Request( "https://api.fatureihoje.com/public/v1/me", headers={ "Authorization": f"Bearer {os.environ['FH_API_KEY']}", "Accept-Language": "en", },)
try: with urllib.request.urlopen(request) as response: body = json.load(response)except urllib.error.HTTPError as failure: error = json.load(failure)["error"] print(f"{failure.code} {error['type']} {error['code']}: {error['message']}", file=sys.stderr) request_id = error.get("request_id") or failure.headers.get("Request-Id") print("request_id", request_id, file=sys.stderr) if error["code"] == "rate_limit_exceeded": print("wait", failure.headers.get("Retry-After"), "seconds", file=sys.stderr) raise SystemExit(1)
print(body["organization"]["name"])Run it with python3 error.py. Standard library only.
Catalog
Section titled “Catalog”This is the whole list. It is generated from the API contract when the site is built, so a new code shows up here on its own and no code is left out.
| Code | HTTP | Type |
|---|---|---|
api_key_invalid | 401 | authentication_error |
access_denied | 403 | permission_error |
permission_missing | 403 | permission_error |
member_permission_denied | 403 | permission_error |
member_suspended | 403 | permission_error |
organization_suspended | 403 | permission_error |
subscription_inactive | 403 | permission_error |
plan_feature_unavailable | 403 | permission_error |
plan_limit_reached | 403 | permission_error |
resource_not_found | 404 | invalid_request_error |
route_not_found | 404 | invalid_request_error |
invalid_request | 400 | invalid_request_error |
validation_failed | 422 | validation_error |
conflict | 409 | conflict_error |
idempotency_key_reused | 422 | validation_error |
idempotency_in_progress | 409 | conflict_error |
unsupported_media_type | 415 | invalid_request_error |
request_too_large | 413 | invalid_request_error |
rate_limit_exceeded | 429 | rate_limit_error |
request_failed | 400 | invalid_request_error |
internal_error | 500 | api_error |
service_unavailable | 503 | api_error |
What to do for each one
Section titled “What to do for each one”-
api_key_invalid -
What to do Check the
Authorization: Bearer <key>header, that the key is still active in the panel, and that your server outbound IP is on the key allow list. The response is the same in every one of those cases on purpose: it does not say which one it was.Read also Authentication
-
access_denied -
What to do Access denied with no known cause. This code goes out when the reason is neither the key permission nor the member permission. Keep the
request_idand contact support. -
permission_missing -
What to do See what the key has in
key.permissions, in theGET /public/v1/meresponse, and compare it with what the route requires in the Reference. A key permission set never changes after creation, not even on rotation: to change it, create another key.Read also Permissions
-
member_permission_denied -
What to do The key has the permission, but the member it acts as cannot do this in the panel. Adjust that member role or permissions, or use a key of another member.
Read also Permissions
-
member_suspended -
What to do Reserved. Today a suspended member falls into the 401
api_key_invalid, because the authentication response must not confirm that the key is valid. If this code shows up, reactivate the member in the panel. -
organization_suspended -
What to do Reserved, for the same reason as the previous one, but for the company. If it shows up, sort out the company status in the panel.
-
subscription_inactive -
What to do Settle the payment in the panel. Retrying before that returns the same error, so looping on it is wasted work.
-
plan_limit_reached -
What to do The most common one is the number of active keys. Check usage in the panel, revoke what you no longer use, or move up a plan.
Read also Rate limits
-
resource_not_found -
What to do The id does not exist, or it belongs to another company. The API answers 404 in both cases, never 403: a 403 would tell you the record exists somewhere else. Check the id and the key company in
GET /public/v1/me. -
route_not_found -
What to do The path does not exist. Check the method, the path and the address:
api.fatureihoje.comserves only/public/v1. A route of an area that is not published yet answers the same way. -
invalid_request -
What to do The request is malformed, almost always broken JSON or an invalid query parameter. Fix it before retrying: the same request returns the same error.
-
validation_failed -
What to do The body carries
errors, withparam,codeandmessagefor each field. Fix what it points at and send again: the same request returns the same error. -
conflict -
What to do Read the resource again and decide from what it says now. Retrying without rereading returns the same conflict. It is what an appointment status change the calendar does not allow answers (cancelled does not come back, completed only goes back to confirmed), an update to a service order that is already completed or cancelled, an update to an approved or cancelled quote, deleting a quote that already became an active sale the second conversion of the same quote into a sale or a service order, starting, completing or cancelling an order already completed or cancelled, any write to a quote absorbed in a merge, an internal code (
sku) that already exists in the company, a variation name repeated within the same product, deleting a category that still has products or services, and on a sale: any write to a canceled sale, a new version or cancellation with an active invoice, a new version with the seller commission already paid, receiving or rescheduling on a contract sale, reversing a payment already reversed or with an active invoice, and rescheduling with no open installment. In finance: changing a protected field of a sale or bank entry, deleting a sale or bank entry, marking as paid an entry that is canceled or on a statement, deleting an account that has entries, and a series delete that would remove more than what was asked (withparamscope). -
idempotency_key_reused -
What to do Generate a new value for every different operation. Reusing a key is only for repeating the SAME request, byte for byte.
Read also Idempotency
-
idempotency_in_progress -
What to do Wait a few seconds and repeat the same request, with the same key. Do not generate a new key: that would run the operation twice.
Read also Idempotency
-
unsupported_media_type -
What to do Form encoding, multipart and plain text are not accepted. Check the
Content-Typeyour library sends by default. -
request_too_large -
What to do Split the payload into smaller parts. The ceiling applies to every call, with or without
Idempotency-Key. -
rate_limit_exceeded -
What to do Wait what the
Retry-Afterheader says and back off progressively. Do not retry in a tight loop: the budget belongs to the company, and you delay its good calls.Read also Rate limits
-
request_failed -
What to do The API did not process the request and the reason has no code of its own in the catalog. Keep the
request_id. If the same request keeps failing, send therequest_idto support. -
internal_error -
What to do The problem is on our side. The response never carries internal detail, on purpose. You may retry after waiting. If it persists, send the
request_idtodev@fatureihoje.com.
Next step
Section titled “Next step”- Rate limits: the 429 and the headers that say how much is left.
- Idempotency: the two errors that show up when you repeat a request.
- Authentication: the 401 checklist, the most common error at the start.