Importing Maintenance Requests from Another System
Fentrica can export every maintenance request of a site as a .fpl package, and import the same format. The format is a published, versioned contract, so you can also write a package yourself — by hand or with a script — to move your history out of another helpdesk, ticket system, spreadsheet, or shared mailbox and into Fentrica.
By the end of this guide you will have built a small package, checked it with Preview, imported it, and know how to do the same through the API.
If you only want to move requests between two Fentrica sites, you do not need this guide — see Importing and Exporting Maintenance Requests.
What you will build
Preview reports every problem before anything is written
Prerequisites
- Admin or Manager access to the target site, which must have Maintenance Requests turned on
- Your source data in a form a script can read (a CSV or JSON export, or an API)
- A ZIP tool and a text editor, or a script that writes both
- For the API route: an API key with the
ticket:read_writepermission
Import never updates or deletes existing requests, but it does create new ones. Try your first package with a handful of requests, and keep in mind that an imported request is a real request with a real reporter address. Nothing is emailed at import, but a later comment from staff on an imported request is sent to the reporter as usual.
The package layout
A .fpl file is a standard ZIP archive with the .fpl extension.
my-helpdesk.fpl
├── manifest.json
└── attachments/
├── 1001/
│ └── photo.jpg
└── 1002/
└── quote.pdf
manifest.jsonsits at the root of the archive, not inside a folder. It lists every request.attachments/holds the files. Everything below it is free-form: you choose the sub-folders. The manifest points at each file by its path.- Files in the archive that the manifest does not mention are ignored.
Select manifest.json and attachments/ and zip those. If you zip the parent folder, manifest.json ends up one level down and the import fails with No manifest.json found in the package.
A minimal manifest
Only three fields are required per request: externalId, title, and state. Only two are required at the top: version and tickets.
{
"$schema": "https://api.building.fentrica.com/orgs/schemas/ticket-export-v1.json",
"version": "1.0",
"tickets": [
{
"externalId": "HELPDESK-1001",
"title": "Leaking tap in the ground floor kitchen",
"state": "open"
}
]
}
That is a valid package. Zip manifest.json alone into minimal.fpl and you can already import it.
A fuller example
This request has a reporter, an assignee, a unit, tags, two comments, and attachments on both the request and a comment.
{
"$schema": "https://api.building.fentrica.com/orgs/schemas/ticket-export-v1.json",
"version": "1.0",
"exportedAt": "2026-09-29T09:00:00Z",
"tickets": [
{
"externalId": "HELPDESK-1002",
"title": "Water stain on the ceiling of the 2nd floor WC",
"description": "A brown stain has appeared next to the light fitting and is growing.",
"state": "resolved",
"priority": 2,
"language": "en",
"scope": "unit",
"safetyHazard": false,
"reporter": {
"email": "[email protected]",
"name": "Mari Maasikas",
"phone": "+3725551234"
},
"assignee": "[email protected]",
"watchers": ["[email protected]"],
"unit": "A-201",
"tags": ["Plumbing", "Roof"],
"resolutionSummary": "Replaced a cracked pipe joint in the void above the ceiling.",
"createdAt": "2026-03-01T08:00:00Z",
"updatedAt": "2026-03-04T12:00:00Z",
"resolvedAt": "2026-03-04T12:00:00Z",
"attachments": [
{ "path": "attachments/1002/stain.jpg", "contentType": "image/jpeg" }
],
"comments": [
{
"author": { "email": "[email protected]", "name": "Tuuli Tehnik" },
"content": "Found the leak. Ordering a replacement joint.",
"createdAt": "2026-03-02T10:15:00Z"
},
{
"author": { "email": "[email protected]" },
"content": "Fixed. The quote and the receipt are attached.",
"createdAt": "2026-03-04T11:50:00Z",
"attachments": [
{ "path": "attachments/1002/quote.pdf", "name": "invoices/quote.pdf", "contentType": "application/pdf" },
{ "path": "attachments/1002/receipt.pdf", "contentType": "application/pdf" }
]
}
]
}
]
}
The matching archive contains manifest.json, attachments/1002/stain.jpg, attachments/1002/quote.pdf, and attachments/1002/receipt.pdf.
Field reference
All names are case-sensitive, and unknown fields are rejected, so a typo such as assigne fails the import instead of being silently ignored.
Package (top level)
| Field | Required | Rule |
|---|---|---|
version | Yes | Must be exactly "1.0" |
tickets | Yes | Array of requests |
$schema | No | Text. Set it to the public schema URL (below) so editors can validate as you type |
exportedAt | No | ISO-8601 date-time, for example 2026-09-29T09:00:00Z |
Request (each item of tickets)
| Field | Required | Rule |
|---|---|---|
externalId | Yes | 1 to 255 characters. Unique within the package. The stable key from your source system, see below |
title | Yes | Not blank, up to 255 characters |
state | Yes | One of new, open, in_progress, on_hold, resolved, closed, canceled |
number | No | Whole number or null. Informational only: it is never used as the request number, the site assigns new numbers |
description | No | Text or null |
notes | No | Text or null |
resolutionSummary | No | Text or null |
priority | No | 0, 1, 2, 3, or null |
language | No | Up to 8 characters or null, for example en or et |
tone | No | neutral, polite, frustrated, angry, or null |
scope | No | unit, common_area, building, or null |
safetyHazard | No | true, false, or null |
reporter | No | A person object or null, see the person rule |
assignee | No | One email address (up to 255 characters) or null |
supervisor | No | One email address (up to 255 characters) or null |
watchers | No | Array of email addresses |
unit | No | Unit name (up to 255 characters) or null |
tags | No | Array of tag names, each not blank, up to 255 characters |
ccEmails | No | Array of email addresses |
feedback | No | Whole number or null |
feedbackText | No | Text or null |
createdAt | No | ISO-8601 date-time or null. When missing, the import time is used |
updatedAt | No | ISO-8601 date-time or null |
resolvedAt | No | ISO-8601 date-time or null. When missing on a resolved or closed request, the state-change time is used |
stateChangedAt | No | ISO-8601 date-time or null. When missing, updatedAt is used, then createdAt |
attachments | No | Array of attachment objects |
comments | No | Array of comment objects, in the order they should appear |
The person object
reporter (on a request) and author (on a comment) use the same shape:
| Field | Rule |
|---|---|
email | Email address (up to 255 characters) or null |
name | Text (up to 255 characters) or null |
phone | Text (up to 64 characters) or null |
At least one of the three must carry a value (a non-blank name or phone, or an email). If you have none, leave the whole person out or set it to null. An object such as { "name": " " } is rejected.
Everywhere else, people are a plain email address: assignee, supervisor, watchers, and ccEmails. Never use Fentrica IDs; the importer matches on email, see how people are matched.
Comments
| Field | Required | Rule |
|---|---|---|
content | Yes | Plain text, no HTML. Must not be blank unless the comment has at least one attachment (a photo-only reply is fine) |
author | No | A person object or null |
createdAt | No | ISO-8601 date-time or null |
attachments | No | Array of attachment objects |
Attachments
The same shape is used on requests and on comments.
| Field | Required | Rule |
|---|---|---|
path | Yes | Location of the file inside the archive. Must start with attachments/ and the file must exist in the archive. No .., no leading /, no backslashes |
name | No | The file name the attachment gets on the request or comment. May contain / to make sub-folders. Defaults to the last part of path. Must be unique among the attachments of the same request, and among the attachments of the same comment |
contentType | No | MIME type, for example image/jpeg. Up to 255 characters |
Each file is limited to 100 MB, see Package limits.
externalId: the stable key
externalId is what makes an import safe to repeat. Use the request's ID in the source system (a ticket number, a database key, a UUID) and keep it the same every time you rebuild the package.
- On import, a request whose
externalIdalready exists on the site is skipped, never updated. Re-running a half-finished migration only adds what is missing. - It must be unique inside one package. Duplicates fail the whole import:
duplicate externalId. - It has nothing to do with the request number the site shows; new numbers are always assigned.
- A package exported from Fentrica keeps the original
externalId, so an export from site A imported into site B, then exported again and imported elsewhere, keeps recognizing the same requests.
Corrected packages do not update old requests
Because existing requests are skipped, importing a corrected package over the top will not change requests that already came in. Check the package with Preview before the real import.
Zipping the package
Zip the contents, so manifest.json is at the root of the archive, then give the file the .fpl extension.
macOS / Linux:
cd my-package
zip -r ../my-helpdesk.fpl manifest.json attachments
Windows: select manifest.json and the attachments folder, right-click, choose Send to > Compressed (zipped) folder, then rename .zip to .fpl.
From a script (Python):
import zipfile
with zipfile.ZipFile("my-helpdesk.fpl", "w", zipfile.ZIP_DEFLATED) as z:
z.write("manifest.json", "manifest.json")
z.write("photos/stain.jpg", "attachments/1002/stain.jpg")
The file name may contain letters (including ä, ö, õ, ü), digits, spaces, and _ - . ( ), and must end in .fpl in any case. No slashes.
Package limits
| Limit | Value |
|---|---|
| Package file | 5 GB |
Files in the package (including manifest.json) | 50,000 |
Any single file, including manifest.json | 100 MB |
| All files added up, uncompressed | 10 GB |
Archives that contain absolute paths, .. segments, backslashes, or the same path twice are rejected as a whole, even when the manifest does not mention the offending file.
Use Preview as your validator
Import is always preceded by Preview: a dry run that reads the whole package, validates it, and writes nothing. It is the fastest way to check a package you built by script.
- Open the site and go to Settings > Import / Export > Maintenance Requests
- Under Import Maintenance Requests, click Upload .fpl Package and select your file. The Import Maintenance Requests window opens and starts checking automatically
- Read the result
A valid package shows counts (Maintenance requests to import, Comments, Attachments), the New tags that would be created, the Unknown units (left empty), and the People not found in this site. Nothing has been written; close the window to stop, or fix and upload again.
An invalid package shows one error line instead. It always starts with Invalid manifest: and lists up to 20 problems separated by semicolons, each with the JSON path, the offending value, and the rule. For example:
Invalid manifest: /tickets/0/state: "done" must be one of: new, open, in_progress, on_hold, resolved, closed, canceled
Invalid manifest: /tickets/2: must not have unknown field "assigne"; must have required field "title"
Invalid manifest: /tickets/5/reporter/email: "mari at example.com" is not a valid email
Problems that need the archive itself are reported with the request they belong to:
Invalid manifest: tickets[3] (externalId "HELPDESK-1004"): duplicate externalId
Invalid manifest: tickets[0] (externalId "HELPDESK-1002").attachments[0].path "attachments/1002/stain.jpg" is not in the package
Invalid manifest: tickets[0] (externalId "HELPDESK-1002").comments[1].attachments[1].name "invoices/quote.pdf" is used twice
Fix the file and upload it again. Repeat until Preview reports counts instead of an error.
Other errors
| Message | Meaning |
|---|---|
The package is not a valid .fpl archive: … | The file is not a ZIP, or is damaged, or breaks one of the limits |
No manifest.json found in the package | manifest.json is missing or is inside a folder in the archive |
manifest.json is not valid JSON | A syntax error, usually a trailing comma or an unescaped quote |
… .path must start with "attachments/" | An attachment path points somewhere else in the archive |
… is not a safe path | An attachment path or name is absolute, contains .., or contains a backslash |
Importing through the API
Scripts can drive the same flow with an API key. Create a key with Maintenance requests set to read & write (the ticket:read_write permission; ticket:read is enough only for exporting), and send it in the X-Api-Key header. See API Keys for how to create one and limit it to specific sites.
The calls go to the site's ticket-packages resource, shown below as $BASE:
https://api.building.fentrica.com/beetle/orgs/{orgId}/properties/{propertyId}/ticket-packages
The site must have Maintenance Requests turned on.
Six calls, two of them polling
1. Get an upload URL
curl -H 'X-Api-Key: lrv_…' \
"$BASE/import/upload-url?filename=my-helpdesk.fpl"
Returns { "url": "…" }, a presigned upload address. The filename follows the file name rule; an invalid name is answered with 400.
2. Upload the package
Send the file to that URL with an HTTP PUT. The body is the raw file, and this request needs no X-Api-Key.
curl -X PUT --upload-file my-helpdesk.fpl "$UPLOAD_URL"
3. Start the preview
curl -X POST -H 'X-Api-Key: lrv_…' -H 'Content-Type: application/json' \
-d '{ "filename": "my-helpdesk.fpl" }' "$BASE/import/preview"
Returns { "jobId": "…" }. 404 means no file has been uploaded under that name.
4. Poll the job
The preview runs as a background job. Read its state from the same resource, with the jobId from step 3. The key needs ticket:read or ticket:read_write:
curl -H 'X-Api-Key: lrv_…' "$BASE/jobs/$JOB_ID"
The answer looks like this while the job is running:
{
"id": "2f6c1c0e-6a53-4f0e-9d0f-6f3a2f6d9a10",
"name": "PREVIEW_TICKET_IMPORT",
"status": "RUNNING",
"resolved": false,
"retryCount": 0,
"maxAttempts": 1,
"scheduled": "2026-09-29T09:00:00.000Z",
"output": null
}
Only jobs of that site's maintenance request packages (export, preview, import) can be read this way; any other job id answers 404. Poll every few seconds, with a growing delay for large packages, and read the state like this:
resolvedistrue: the job has finished for good.statusis thenSUCCESSorERROR, andoutputholds the result.statusisERRORbutresolvedisfalse: the attempt failed and a retry is pending. Keep polling.retryCountis the number of attempts so far andmaxAttemptsthe most there will be, roughly one minute apart. Only an import is retried; a preview hasmaxAttemptsof1.- Any other
status(SCHEDULED,RUNNING) withresolvedfalse: still waiting or running.
Large packages can take a long time: a preview may run for up to 30 minutes, and an import for up to an hour per attempt, with up to 3 attempts about a minute apart. An import job can therefore take up to about 3 hours to reach its final state.
On SUCCESS, output is the preview:
{
"tickets": { "total": 1200, "new": 1150, "existing": 50 },
"comments": 5400,
"attachments": 3100,
"tagsToCreate": ["Roof"],
"unmatchedUnits": ["B-9"],
"unmatchedEmails": [{ "email": "[email protected]", "roles": ["assignee", "watcher"] }]
}
On a final ERROR, output.message holds the same readable error as the screen.
5. Start the import
curl -X POST -H 'X-Api-Key: lrv_…' -H 'Content-Type: application/json' \
-d '{ "filename": "my-helpdesk.fpl" }' "$BASE/import"
Returns { "jobId": "…" }. This call only checks the filename and that the file exists; the job does the validating, so run the preview first.
6. Poll the import job
Poll as in step 4. On SUCCESS, output reports what happened:
{
"created": { "tickets": 1150, "comments": 5400, "attachments": 3100, "tags": 1 },
"skipped": 50,
"unmatchedEmails": [{ "email": "[email protected]", "roles": ["assignee", "watcher"] }],
"unmatchedUnits": ["B-9"]
}
If an import fails on an unexpected platform problem, it is retried automatically and each retry continues where the last stopped, because requests already written are skipped. When the last attempt fails too, the final message reads like this, and running the import again continues from there:
Import stopped at maintenance request 401 of 1200: 400 of them are already on the site. An unexpected error occurred and has been logged — running the import again continues from here.
After a successful import the uploaded package is deleted; after a failed or previewed one it stays so you can fix and retry.
The public schema
The manifest format is described by a JSON Schema (draft-07):
https://api.building.fentrica.com/orgs/schemas/ticket-export-v1.json
Point your editor or validator at it, or set $schema to it in the manifest, and mistakes such as an unknown state or a misspelled field are caught while you type.
Tips for migration scripts
- Keep a mapping table from your source's status values to the seven
statevalues. Anything you cannot map cleanly is usuallyopen. - Send the history, not just the latest state. Comments keep their order and their
createdAt, so the conversation reads correctly on the imported request. - Use real emails for people who work on the site. Reporters and comment authors who match a person on the site are linked to them, and everyone else is kept as text; assignees, supervisors, and watchers who do not match are skipped. Check the Preview list of People not found in this site before importing.
- Treat the package as personal data. It holds names, emails, phone numbers, and conversation history. Delete your copies once the import is checked, and do not share them beyond the people doing the migration.