Fronteam API
Connect your ERP, CRM or booking system to the Fronteam Logistics calendar. Your system creates, updates and cancels events and gets every change made in Fronteam back (webhooks or polling); anything that would affect assigned crew waits for a manager's decision, and the API never sends a message to employees.
REST + JSONBase URL https://fline.app/api/v1 Version 1.0Enabled per company by the Fronteam adminGetting started
- A manager of the company opens External system connection in Fronteam Logistics, creates a
connection and maps your status codes (for example
CONFIRMED) to their statuses. - They create a test key (
ftk_test_…) and turn the connection on, with Receive changes from their system checked. While the connection is off, every request is rejected (even with a test key); without receiving changes you can only read. With a test key every write is a dry run: the response tells what would happen, and nothing changes. - They send you the access details. When your integration works, they create a live key
(
ftk_live_…). - To get the changes made in Fronteam, they enter your webhook URL and check Send changes to their
system; the signing secret (
whsec_…) is shown to them once. Without a webhook you can pollGET /changes. See Changes from Fronteam.
The machine-readable spec: https://fline.app/api/v1/openapi.json (OpenAPI 3.1, no key needed).
notes or description: the crew assigned to the event can
read them.
Authentication
Authorization: Bearer ftk_live_0123…cdef
If your tool cannot send a Bearer header, send X-Api-Key: ftk_live_… instead. A key is shown to the
manager only once; Fronteam keeps only a fingerprint of it. A revoked or expired key gets 401 right away.
Keys can be read-only (events:read) or read and write (events:write). Only
https://fline.app/api/v1 is accepted - requests sent anywhere else get 403 proxy_required.
A key (and the webhook address) exposes all events with their customer details, so only a manager who may view events and customers can create one; a key that writes also needs permission to edit them.
Requests
- Bodies are JSON (
Content-Type: application/json), up to 256 KB (batch: 2 MB). - Idempotency: send
Idempotency-Keyon writes. A retry with the same key and body returns the first answer (Idempotency-Replayed: true) for 24 hours; the same key with a different body is422 idempotency_key_reused. - Concurrency: every event has an
ETag. SendIf-Matchto update only the version you read; otherwise409 revision_mismatch. The revision also changes when Fronteam-only fields change - on409,GETthe event and try again. - Rate limits: per connection (default 120 requests per minute; batch up to 100 events). Every
answer carries
RateLimit-Limit,RateLimit-RemainingandRateLimit-Reset; over the limit you get429withRetry-After. - Dates are
YYYY-MM-DDand timesHH:MM, in the company's time zone; timestamps are UTC (ISO 8601). A field you do not send is not touched;nullclears it. - Every answer has
X-Request-Id- quote it when you contact support.
Endpoints
| Method | Path | What it does |
|---|---|---|
| GET | /me | The connection, company, key and limits. |
| GET | /statuses | The company's statuses, the code we send for each and the codes you may send. |
| GET | /events | Events page by page: updated_since, from, to, status, include_cancelled, include_deleted, limit (up to 200), cursor. |
| GET | /events/{external_id} | An event by your id. |
| GET | /events/by-fronteam-id/{id} | An event by our id. |
| PUT | /events/{external_id} | Create (first time) or update (merge). ?dry_run=true = only tell what would happen. |
| PATCH | /events/{external_id} | Update an event you already linked (404 if not). |
| DELETE | /events/{external_id} | Cancel the event (it is never deleted). |
| POST | /events/batch | Up to 100 upserts / patches / cancels; each item succeeds or fails on its own. |
| DELETE | /events/{external_id}/link | Forget the link between your id and the event (the event stays). |
| GET | /changes | What changed in Fronteam since your cursor - the latest change of each event, oldest first. |
| GET | /openapi.json | This API as OpenAPI 3.1. |
Your external_id: letters, digits and . _ : ~ -, up to 128 characters. To link an event that
already exists in Fronteam, send its fronteam_id in the first PUT (only when the connection covers all
company events; otherwise an unlinked event is "not found" for you).
The event object
The same shape in every GET and in every write answer:
{
"external_id": "ORD-1001",
"fronteam_id": "7c1f…",
"revision": 4,
"name": "Product launch",
"description": null,
"notes": "Load-in through gate 3",
"status": { "code": "CONFIRMED", "name": "Confirmed", "cancelled": false },
"dates": { "start": "2026-11-05", "end": "2026-11-06" },
"times": { "setup": "08:00", "start": "18:00", "end": "23:30" },
"venue": { "name": "Expo Hall 2", "address": "Rokach Blvd 101", "contact_name": "Dana", "contact_phone": "+972501234567" },
"customer": { "name": "Acme Ltd", "tax_id": "514000000", "phone": null, "email": "ops@acme.com",
"fronteam_id": "0b9e…" },
"contact": { "name": "Noa", "phone": "+972521112222", "email": "noa@acme.com" },
"sales_agent": { "code": "AG-12", "name": "Avi" },
"schedule": [ { "name": "Setup", "date": "2026-11-05", "start": "08:00", "end": "16:00" } ],
"sync": { "state": "synced", "pending": [] },
"deleted": false,
"updated_at": "2026-10-27T08:15:00Z"
}
customer: matched by tax id, then name, phone and email; a new customer is created only if the company
allows it. Existing customers are never changed by the API. schedule is used when the event is created;
a later change becomes a note for the manager. sales_agent is the code on the agent's employee card.
Writes and decisions
| Status | result | Meaning |
|---|---|---|
| 201 | created | A new event. |
| 200 | updated / unchanged | Applied, or nothing changed (no new revision). |
| 202 | pending_review / partially_applied | Something waits for a manager - see held. |
202 with the first reason that applies
(crew_assigned / pending_invites / scheduled_messages /
pending_approvals / equipment_out / conflicts) and the numbers, the manager
sees it on the event, and the event updates when they decide - sync.pending shows what still waits. Venue or time changes are applied and the
manager is asked whether to notify the crew. The API itself never sends a message to employees.
HTTP/1.1 202 Accepted
{
"result": "pending_review",
"applied_fields": [],
"held": [ { "review_id": "3f2a…", "kind": "cancel_impact", "fields": ["status"], "reason": "crew_assigned",
"impact": { "assigned": 5 } } ],
"conflicts": [],
"warnings": [],
"event": { "external_id": "ORD-1001", "status": { "code": "CONFIRMED" }, "sync": { "state": "pending_review" } }
}
Both sides changed the same field: by default the value set in Fronteam wins and the conflict is
reported to the manager (conflicts); the company can choose "theirs wins" or "ask me" instead.
Direction per field: for each field the company can choose both ways (the default), only from you,
only from Fronteam, or not synced. A field that is "only from Fronteam" or "not synced" is not taken from your
writes - the response says so in warnings (example below) and the rest of the request is applied; on
create, name and dates.start are always taken. A field that is "only from you" or "not
synced" is left out of everything we send you (GET, webhooks, /changes) - so a missing
field means "not sent", never "cleared". Cancelling (DELETE) always goes through, whatever the
direction of status.
"warnings": [ { "code": "field_not_writable", "field": "notes" } ]
Changes from Fronteam
When a manager edits, moves, cancels or deletes a linked event in Fronteam, your system hears about it - by
webhook (we call you) or by polling GET /changes (you ask us). A change
you made through the API is never sent back to you.
Webhooks
POST https://erp.example.com/fronteam/hook
webhook-id: 6d1c2b0e-…
webhook-timestamp: 1761552900
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{ "type": "event.updated", "timestamp": "2026-10-27T08:15:00Z",
"data": { "sequence": 1851, "event": { "external_id": "ORD-1001", "revision": 5, … } } }
type | When |
|---|---|
event.created / event.updated | A linked event was created or changed in Fronteam (name, dates, times, venue, status, customer, notes, schedule…). |
event.cancelled / event.uncancelled | Moved to / out of a cancelled status. |
event.deleted / event.restored | Deleted (you get a tombstone with recoverable_until) / restored from the recycle bin. |
change.rejected | Part of your write was not applied: Fronteam kept its own value in a conflict, or a manager declined a change that waited for a decision (data.held[].decision). data.conflicts / data.held as in the write answer, data.request_id = your request's X-Request-Id. Webhook only (not in GET /changes). |
ping | The manager pressed "Send test". |
data.eventis always the event's current state, so several quick changes arrive as one delivery. If two deliveries of the same event arrive out of order, keep the one with the higherdata.sequence. Treatevent.updatedfor an event you don't know as a create.- Editing a customer card alone doesn't send anything - the event's customer details go out with its next change.
- Answer
2xxwithin 10 seconds. Anything else - including redirects, which are not followed - is retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h, then dropped (the manager can send it again).webhook-idand the body are the same on every retry: use the id to ignore duplicates. If the event changes while a delivery waits for a retry, that delivery is replaced by a new one (a newwebhook-id) with the current state. - Answer
410 Goneto stop the deliveries. After 72 hours of failures the webhook is turned off as well, and the manager is told. - Your URL must be
httpson port 443 and public (internal addresses are refused). We have no fixed outgoing IP address - verify the signature instead of an IP allow-list. - Which events: only events linked to you, unless the Fronteam admin chose "all company events" - then you also
get events you never sent (
external_id: null), and that is the only case whereevent.createdarrives (an event you create through the API is already yours).
Checking the signature
We follow Standard Webhooks - you can use their library for your language, or check it yourself:
signature = base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw body}"))
where key is the base64-decoded part of your secret after whsec_. While the manager rotates
the secret, webhook-signature holds two signatures separated by a space - accept the request if one
matches. Reject timestamps more than 5 minutes away.
import crypto from 'node:crypto';
// app.post('/fronteam/hook', express.raw({ type: 'application/json' }), (req, res) => …)
function verifyFronteam(req, secret) {
const id = req.headers['webhook-id'];
const ts = Number(req.headers['webhook-timestamp']);
if (!id || !ts || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.${req.body}`).digest('base64');
return String(req.headers['webhook-signature'] || '').split(' ').some((s) => {
const [v, sig = ''] = s.split(',');
return v === 'v1' && sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
});
}
Polling: GET /changes
GET /changes?cursor=1842
{ "changes": [ { "sequence": 1851, "type": "event.cancelled", "occurred_at": "2026-10-27T08:15:00Z",
"event": { "external_id": "ORD-1001", … } } ],
"next_cursor": "1851", "has_more": false }
Keep next_cursor and send it next time (first call - without a cursor). has_more: true -
call again right away. Events come in the order their changes were saved, each in its current state; a change saved
late comes after what you already got - never skipped. Works only while sending is on (otherwise
403 outbound_disabled). Changes are kept 30 days: a cursor older than that, or from before sending was
turned off and on again, gets 410 cursor_expired - read GET /events again and continue
without a cursor.
Errors
{ "error": { "code": "validation_failed", "message": "…", "request_id": "…",
"details": { "errors": [ { "field": "dates.end", "reason": "on or after dates.start, up to 366 days" } ] } } }
| Status | Codes |
|---|---|
| 400 | invalid_json invalid_request invalid_cursor unknown_status |
| 401 | missing_api_key invalid_api_key |
| 403 | insufficient_scope integration_disabled inbound_disabled outbound_disabled company_inactive ip_not_allowed proxy_required |
| 404 / 405 | route_not_found event_not_found method_not_allowed |
| 409 | revision_mismatch already_linked external_id_conflict idempotency_in_progress employee_on_option |
| 410 | event_deleted cursor_expired |
| 413 / 415 | payload_too_large unsupported_media_type |
| 422 | validation_failed unknown_field idempotency_key_reused no_cancel_status |
| 429 | rate_limited too_many_failed_auth |
| 5xx | internal_error read_only_transaction not_implemented integrations_paused server_busy timeout - retry with the same Idempotency-Key (Retry-After when given). |