Tutorials

Video tutorials and step-by-step guides to help you learn Fentrica.

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

Map your fieldsWrite manifest.jsonAdd attachmentsZip as .fplPreviewImport

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_write permission
Test on a site you can clean up

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.json sits 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.
Zip the contents, not the folder

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)

FieldRequiredRule
versionYesMust be exactly "1.0"
ticketsYesArray of requests
$schemaNoText. Set it to the public schema URL (below) so editors can validate as you type
exportedAtNoISO-8601 date-time, for example 2026-09-29T09:00:00Z

Request (each item of tickets)

FieldRequiredRule
externalIdYes1 to 255 characters. Unique within the package. The stable key from your source system, see below
titleYesNot blank, up to 255 characters
stateYesOne of new, open, in_progress, on_hold, resolved, closed, canceled
numberNoWhole number or null. Informational only: it is never used as the request number, the site assigns new numbers
descriptionNoText or null
notesNoText or null
resolutionSummaryNoText or null
priorityNo0, 1, 2, 3, or null
languageNoUp to 8 characters or null, for example en or et
toneNoneutral, polite, frustrated, angry, or null
scopeNounit, common_area, building, or null
safetyHazardNotrue, false, or null
reporterNoA person object or null, see the person rule
assigneeNoOne email address (up to 255 characters) or null
supervisorNoOne email address (up to 255 characters) or null
watchersNoArray of email addresses
unitNoUnit name (up to 255 characters) or null
tagsNoArray of tag names, each not blank, up to 255 characters
ccEmailsNoArray of email addresses
feedbackNoWhole number or null
feedbackTextNoText or null
createdAtNoISO-8601 date-time or null. When missing, the import time is used
updatedAtNoISO-8601 date-time or null
resolvedAtNoISO-8601 date-time or null. When missing on a resolved or closed request, the state-change time is used
stateChangedAtNoISO-8601 date-time or null. When missing, updatedAt is used, then createdAt
attachmentsNoArray of attachment objects
commentsNoArray 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:

FieldRule
emailEmail address (up to 255 characters) or null
nameText (up to 255 characters) or null
phoneText (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

FieldRequiredRule
contentYesPlain text, no HTML. Must not be blank unless the comment has at least one attachment (a photo-only reply is fine)
authorNoA person object or null
createdAtNoISO-8601 date-time or null
attachmentsNoArray of attachment objects

Attachments

The same shape is used on requests and on comments.

FieldRequiredRule
pathYesLocation of the file inside the archive. Must start with attachments/ and the file must exist in the archive. No .., no leading /, no backslashes
nameNoThe 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
contentTypeNoMIME 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 externalId already 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

LimitValue
Package file5 GB
Files in the package (including manifest.json)50,000
Any single file, including manifest.json100 MB
All files added up, uncompressed10 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.

  1. Open the site and go to Settings > Import / Export > Maintenance Requests
  2. Under Import Maintenance Requests, click Upload .fpl Package and select your file. The Import Maintenance Requests window opens and starts checking automatically
  3. 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

MessageMeaning
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 packagemanifest.json is missing or is inside a folder in the archive
manifest.json is not valid JSONA 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 pathAn 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

GET import/upload-urlPUT the filePOST import/previewPoll the jobPOST importPoll the job

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:

  • resolved is true: the job has finished for good. status is then SUCCESS or ERROR, and output holds the result.
  • status is ERROR but resolved is false: the attempt failed and a retry is pending. Keep polling. retryCount is the number of attempts so far and maxAttempts the most there will be, roughly one minute apart. Only an import is retried; a preview has maxAttempts of 1.
  • Any other status (SCHEDULED, RUNNING) with resolved false: 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 state values. Anything you cannot map cleanly is usually open.
  • 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.

Next steps