Create service orders from another system
A ticket comes into your help desk, your monitoring center or your ERP, and it needs to become a service order for the field team. This guide opens the order through the API and shows how to follow what happens to it afterwards.
An order created through the API is the same as one created in the dashboard: it counts against the plan quotas, gets the company’s number, has its total calculated and applies the automatic checklists the company configured.
The key
Section titled “The key”| Permission | What for |
|---|---|
service_orders:read | Read the order types and follow the order |
service_orders:create | Create the order |
service_orders:update | Change status, start, complete and cancel |
team:read | Optional. Find the technician in charge |
appointments:create | Only if the order will create the calendar appointment (create_appointment) |
finance:create | Only if completing will post the revenue (link_financial_transaction) |
quotes:read | Only to convert a quote into an order (from_quote). The member also needs access to quotes |
Before the first order: what the company requires
Section titled “Before the first order: what the company requires”Three reads prevent almost every 422 before it happens. Do them once and keep the result; they rarely change.
- Order types.
GET /public/v1/service_order_typeslists the registered types (CCTV, alarm, gate). Each one’sidis the value fortype_id. Whenrequired_on_createistrue, the company requires a type, and creating withouttype_idreturns 422. A type withactive: falsewas removed from the dashboard picker. - Technicians.
GET /public/v1/teamreturns the team. Technicians come withtype: "technician", and theiridgoes intechnician_idand insupport_technician_ids. - Client. The order can point to a record (
client_id), and then it must belong to the company. If you do not link clients between the two systems yet, see Sync clients with a CRM or ERP.
The company can also mark fields as required when opening an order, in the dashboard’s service order settings. The API enforces the same fields, and whatever is missing comes back in errors on the 422.
Create the order
Section titled “Create the order”# 1. The company's order types (keep them; they rarely change)curl "https://api.fatureihoje.com/public/v1/service_order_types?limit=100" \ -H "Authorization: Bearer $FH_API_KEY"
# 2. Create the order. The idempotency key comes from the ticket number.curl -X POST "https://api.fatureihoje.com/public/v1/service_orders" \ -H "Authorization: Bearer $FH_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: ticket-4821" \ -d '{ "client_id": "0d9a7c5e-3f1b-4d2a-8c6e-4a2b0c8e6f1d", "client_name": "Marcenaria Souza", "client_phone": "+5511988887777", "client_description": "The electric gate does not close all the way.", "service_address": "Rua das Flores, 120, São Paulo, SP", "type_id": "7b1e4c2a-9d3f-4a6b-8c5e-1f2a3b4c5d6e", "scheduled_start": "2026-10-05T12:00:00Z", "scheduled_end": "2026-10-05T14:00:00Z", "priority": "high", "internal_notes": "Ticket 4821 from the help desk", "items": [ { "description": "Technical visit", "quantity": 1, "unit_price_cents": 15000, "total_cents": 15000 } ] }'const API = 'https://api.fatureihoje.com/public/v1';const auth = { Authorization: `Bearer ${process.env.FH_API_KEY}` };
// The ticket as it is in your system.const ticket = { number: 4821, clientId: '0d9a7c5e-3f1b-4d2a-8c6e-4a2b0c8e6f1d', // the Faturei id you stored clientName: 'Marcenaria Souza', phone: '+5511988887777', problem: 'The electric gate does not close all the way.', address: 'Rua das Flores, 120, São Paulo, SP', kind: 'Gate',};
async function json(res) { const body = await res.json(); if (!res.ok) throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`); return body;}
// 1. The company's order types. In production, read once and keep them.const types = await json(await fetch(`${API}/service_order_types?limit=100`, { headers: auth }));const type = types.data.find((t) => t.active && t.name === ticket.kind);
// 2. Create the order.const order = await json( await fetch(`${API}/service_orders`, { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json', // Same ticket, same key: sending it again does not open another order. 'Idempotency-Key': `ticket-${ticket.number}`, }, body: JSON.stringify({ client_id: ticket.clientId, client_name: ticket.clientName, client_phone: ticket.phone, client_description: ticket.problem, service_address: ticket.address, type_id: type ? type.id : null, scheduled_start: '2026-10-05T12:00:00Z', scheduled_end: '2026-10-05T14:00:00Z', priority: 'high', internal_notes: `Ticket ${ticket.number} from the help desk`, items: [{ description: 'Technical visit', quantity: 1, unit_price_cents: 15000, total_cents: 15000 }], }), }),);
// Store order.id on the ticket.console.log(order.id, order.status);<?php
const API = 'https://api.fatureihoje.com/public/v1';
function request(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array{ $headers = ['Authorization: Bearer ' . getenv('FH_API_KEY')]; if ($body !== null) { $headers[] = 'Content-Type: application/json'; } if ($idempotencyKey !== null) { $headers[] = 'Idempotency-Key: ' . $idempotencyKey; } $ch = curl_init(API . $path); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); } $data = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); if ($status >= 400) { throw new RuntimeException($status . ' ' . $data['error']['code'] . ': ' . $data['error']['message']); } return $data;}
// The ticket as it is in your system.$ticket = [ 'number' => 4821, 'client_id' => '0d9a7c5e-3f1b-4d2a-8c6e-4a2b0c8e6f1d', // the Faturei id you stored 'client_name' => 'Marcenaria Souza', 'phone' => '+5511988887777', 'problem' => 'The electric gate does not close all the way.', 'address' => 'Rua das Flores, 120, São Paulo, SP', 'kind' => 'Gate',];
// 1. The company's order types. In production, read once and keep them.$typeId = null;foreach (request('GET', '/service_order_types?limit=100')['data'] as $type) { if ($type['active'] && $type['name'] === $ticket['kind']) { $typeId = $type['id']; }}
// 2. Create the order. Same ticket, same key: sending it again does not open another order.$order = request('POST', '/service_orders', [ 'client_id' => $ticket['client_id'], 'client_name' => $ticket['client_name'], 'client_phone' => $ticket['phone'], 'client_description' => $ticket['problem'], 'service_address' => $ticket['address'], 'type_id' => $typeId, 'scheduled_start' => '2026-10-05T12:00:00Z', 'scheduled_end' => '2026-10-05T14:00:00Z', 'priority' => 'high', 'internal_notes' => 'Ticket ' . $ticket['number'] . ' from the help desk', 'items' => [ ['description' => 'Technical visit', 'quantity' => 1, 'unit_price_cents' => 15000, 'total_cents' => 15000], ],], 'ticket-' . $ticket['number']);
// Store the id on the ticket.echo $order['id'], ' ', $order['status'], PHP_EOL;import jsonimport osimport urllib.errorimport urllib.request
API = "https://api.fatureihoje.com/public/v1"
def request(method, path, body=None, idempotency_key=None): headers = {"Authorization": f"Bearer {os.environ['FH_API_KEY']}"} data = None if body is not None: headers["Content-Type"] = "application/json" data = json.dumps(body).encode() if idempotency_key: headers["Idempotency-Key"] = idempotency_key req = urllib.request.Request(API + path, data=data, method=method, headers=headers) try: with urllib.request.urlopen(req) as response: return json.load(response) except urllib.error.HTTPError as failure: error = json.load(failure)["error"] raise SystemExit(f"{failure.code} {error['code']}: {error['message']}")
# The ticket as it is in your system.ticket = { "number": 4821, "client_id": "0d9a7c5e-3f1b-4d2a-8c6e-4a2b0c8e6f1d", # the Faturei id you stored "client_name": "Marcenaria Souza", "phone": "+5511988887777", "problem": "The electric gate does not close all the way.", "address": "Rua das Flores, 120, São Paulo, SP", "kind": "Gate",}
# 1. The company's order types. In production, read once and keep them.types = request("GET", "/service_order_types?limit=100")["data"]type_id = next((t["id"] for t in types if t["active"] and t["name"] == ticket["kind"]), None)
# 2. Create the order. Same ticket, same key: sending it again does not open another order.order = request( "POST", "/service_orders", { "client_id": ticket["client_id"], "client_name": ticket["client_name"], "client_phone": ticket["phone"], "client_description": ticket["problem"], "service_address": ticket["address"], "type_id": type_id, "scheduled_start": "2026-10-05T12:00:00Z", "scheduled_end": "2026-10-05T14:00:00Z", "priority": "high", "internal_notes": f"Ticket {ticket['number']} from the help desk", "items": [ {"description": "Technical visit", "quantity": 1, "unit_price_cents": 15000, "total_cents": 15000} ], }, f"ticket-{ticket['number']}",)
# Store the id on the ticket.print(order["id"], order["status"])What is worth knowing about the fields (the full list is in the Reference):
client_nameis required even withclient_id: it is the name as printed on the order, and it stays there if the record is deleted.- With
scheduled_startthe order starts asscheduled; without it,pending. - Money goes in cents. The dashboard stores each item’s
total_centsas you sent it, without recalculating from the quantity, and the order subtotal is their sum. See Dates and money. - The order has no field for your system’s id. Store the order
idon your ticket and, if you want the team to see the ticket number, put it ininternal_notes, which does not appear on the client’s document.
The idempotency key comes from the ticket
Section titled “The idempotency key comes from the ticket”In the example, the Idempotency-Key is ticket-4821. So if your system sends the same ticket twice (a queue retry, an operator clicking again), the second call returns the order from the first one instead of opening another.
Two things to keep in mind:
- The response is kept for 24 hours. After that, the same key creates again. So store the order
idon the ticket as soon as it comes back, and check that field before creating. - The same key with a different body returns 422
idempotency_key_reused. If the ticket changed before the order was created, it is a different operation.
See Idempotency.
Side effects you can turn on
Section titled “Side effects you can turn on”They are the same as in the dashboard form, and off by default:
send_to_technician: truenotifies the technician in charge and the support team over WhatsApp after creating. Withouttechnician_id, nobody is notified.create_appointment: true, together withscheduled_start, creates the calendar appointment and mirrors it to the Google Calendar of whoever connected one. It needsappointments:createon the key.
After it is created
Section titled “After it is created”The order moves through the same actions as the dashboard buttons:
| Action | Route |
|---|---|
| Change status (any to any) | POST /public/v1/service_orders/{id}/status |
| Start | POST /public/v1/service_orders/{id}/start |
| Complete, optionally posting the revenue | POST /public/v1/service_orders/{id}/complete |
| Cancel | POST /public/v1/service_orders/{id}/cancel |
| Edit | PATCH /public/v1/service_orders/{id} |
A completed or canceled order does not accept edit, start, complete or cancel: the response is 409 conflict. To reopen it, change the status through the status route. Completing respects the required checklist and the technician’s completion order, and returns 409 when something is missing.
To know when the technician started or finished, do not keep polling the order. Subscribe an endpoint to service_order.status_changed and service_order.updated (see Create an endpoint), and use incremental sync as a safety net.
An approved quote becomes an order
Section titled “An approved quote becomes an order”If your flow starts with a Faturei Hoje quote, do not recreate the items by hand: POST /public/v1/service_orders/from_quote converts an approved quote into an order, through the same conversion as the dashboard dialog. It needs quotes:read on the key and access to quotes for the key’s member.
When the call fails
Section titled “When the call fails”| Response | What to do |
|---|---|
422 validation_failed | An invalid field, or a field the company requires is missing. The field is in errors |
404 resource_not_found | A client or technician that does not exist or does not belong to the company |
403 plan_limit_reached | The plan’s order quota is used up. The company sorts it out in the dashboard |
403 permission_missing | The key is missing a permission, such as appointments:create with create_appointment |
429 rate_limit_exceeded | Wait for Retry-After and retry. See Rate limits |
| 5xx or network failure | Retry with the same Idempotency-Key |
Next step
Section titled “Next step”- Record sales and receive payments: the financial side of the job.
- Event catalog: what each order event carries.