Effektivflyt Cases API (v1)
Other systems (a webshop, a customer portal, an ERP, a web form) create cases in Effektivflyt. The team works them like email: assign, tag, wait, resolve, reply. The replies go back to your system.
Concepts
- Channel: a named API inbox in a company, e.g. Webshop orders. It has its own address in the
case list (
webshop-orders-1a2b@api.effektivflyt.no), its own keys, and optionally a webhook. Admins make them under Settings → Admin → API & channels. - Key:
efk_<company>_<secret>. One per sending system, so one can be revoked without stopping the others. Shown once when made; Effektivflyt keeps only a hash. - Case: what you create. It gets an id like
case_8x2kQ…and, if you send one, keeps your own reference asexternalId.
Calling the API
Base: https://<your Effektivflyt address>/api/v1 (locally: http://localhost:5173/api/v1)
Auth: Authorization: Bearer efk_…
Body: Content-Type: application/json
Errors are always JSON with a stable code:
{ "error": { "code": "requester_email_invalid", "message": "`requester.email` must be an email address." } }
| Status | When |
|---|---|
| 401 | missing_key, invalid_key (wrong or revoked) |
| 404 | case_not_found (in this channel) |
| 415 | json_required |
| 422 | invalid input (subject_required, body_required, attachment_too_large, …) |
| 429 | rate_limited: more than 120 requests a minute per key (see Retry-After) |
Check a key: GET /ping
curl https://app.example/api/v1/ping -H "Authorization: Bearer $KEY"
# { "ok": true, "company": "Logent AS", "channel": { "id": "…", "name": "Webshop orders" } }
Create a case: POST /cases
| Field | ||
|---|---|---|
subject |
required | at most 300 characters |
text or html |
one required | the message; HTML is cleaned before it is shown |
requester.email |
required | who the case is from |
requester.name |
optional | |
externalId |
optional, recommended | your own reference (order number). Sending the same one again returns the existing case (200 and Effektivflyt-Idempotent-Replay: true) instead of making a second. The Idempotency-Key header does the same. |
tags |
optional | names of tags that exist in Effektivflyt; others are listed back as ignoredTags |
urgent |
optional | true marks it urgent |
attachments |
optional | [{ "name": "order.pdf", "contentType": "application/pdf", "contentBase64": "…" }], at most 10 files, 10 MB each, 25 MB together |
curl -X POST https://app.example/api/v1/cases \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{
"subject": "Order 4471: 40 pallets to Bergen",
"text": "Hi! Can you confirm delivery on Tuesday?",
"requester": { "name": "Ola Nordmann", "email": "ola@nordicbyg.no" },
"externalId": "order-4471",
"tags": ["Order"]
}'
Answer (201):
{
"case": {
"id": "case_8x2kQm1vZp0a",
"externalId": "order-4471",
"subject": "Order 4471: 40 pallets to Bergen",
"status": "open",
"assignee": null,
"urgent": false,
"tags": ["Order"],
"requester": { "name": "Ola Nordmann", "email": "ola@nordicbyg.no" },
"createdAt": "2026-11-08T10:00:00.000Z",
"messages": [
{ "id": "api-msg-…", "direction": "in", "from": { "name": "Ola Nordmann", "address": "ola@nordicbyg.no" },
"text": "Hi! Can you confirm delivery on Tuesday?", "at": "2026-11-08T10:00:00.000Z", "attachments": [] }
]
}
}
status is one of open, waiting (the team answered and waits for the requester), on_hold, resolved.
Read a case: GET /cases/{id} or GET /cases?externalId=order-4471
Same shape as above, with every message: direction: "in" from the requester, "out" from the team.
Internal notes are never included.
List cases: GET /cases
The channel's cases, newest first. status (open, waiting, on_hold, resolved), updatedSince
(ISO time: only cases with something newer), limit (1–100, default 25) and cursor (from the
previous page's nextCursor).
curl "https://app.example/api/v1/cases?status=open&limit=50" -H "Authorization: Bearer $KEY"
# { "cases": [ … ], "nextCursor": "1794162831000.case_…" } (null on the last page)
Change a case: PATCH /cases/{id}
Send any of these; what you leave out stays as it is. The change is made exactly as if someone
clicked it in the app, and the case history says it came from <channel name> (API).
| Field | |
|---|---|
status |
open, waiting, on_hold or resolved |
holdReason, followUpAt |
with on_hold: why (required) and when to follow up (optional, ISO time) |
assignee |
the email of someone in the company (see GET /team), or null to unassign |
urgent |
true or false |
tags |
the full list of tag names the case should have (see GET /tags); [] removes all |
curl -X PATCH https://app.example/api/v1/cases/case_8x2kQm1vZp0a \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "assignee": "mette@company.no", "tags": ["Order"], "urgent": true }'
Errors: status_invalid, hold_invalid, assignee_unknown, tags_unknown.
Internal notes: GET / POST /cases/{id}/notes
Notes are for the team and never reach the requester. POST { "text": "…", "author": "Shop bot" }
adds one (shown as Shop bot (Webshop orders (API))). GET /cases/{id}?include=notes includes them
in the case.
Tags and team: GET /tags, GET /team
The company's tags by name, and the people a case can be assigned to (name and email; someone appears once they have opened the company).
Add a follow-up: POST /cases/{id}/messages
When the requester writes again in your system. It reopens the case if it was resolved.
curl -X POST https://app.example/api/v1/cases/case_8x2kQm1vZp0a/messages \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{ "text": "Tuesday works. Please call before arrival." }'
Webhooks (replies and status back to you)
Set a webhook address on the channel (HTTPS; http://localhost is allowed for testing). Effektivflyt
then POSTs JSON to it:
| Event | When | data |
|---|---|---|
case.replied |
someone on the team replied | caseId, externalId, messageId, author, text, formatted, attachments[] |
case.status_changed |
open / waiting / on_hold / resolved changed | caseId, externalId, from, to, by |
case.assigned |
the case got (another) owner | caseId, externalId, assignee, by |
case.note_added |
someone added an internal note | caseId, externalId, text, by |
case.tags_changed |
a tag was added or removed | caseId, externalId, added or removed, by |
case.urgent_changed |
marked urgent or not | caseId, externalId, urgent, by |
channel.test |
Send a test case in Settings | caseId, message |
{ "id": "evt_…", "type": "case.replied", "createdAt": "…", "channel": { "id": "…", "name": "Webshop orders" },
"data": { "caseId": "case_8x2kQm1vZp0a", "externalId": "order-4471", "author": "Mette Jensen", "text": "Yes, Tuesday before 12." } }
Headers: Effektivflyt-Event, Effektivflyt-Delivery (the event id, to ignore repeats) and
Effektivflyt-Signature: t=<unix seconds>,v1=<hex>. Answer with any 2xx within 10 seconds.
Anything else is tried again after 1 min, 5 min, 30 min, 2 h and 12 h, then given up; Settings
shows each delivery and can send one again.
Check the signature (the secret is in Settings → API & channels → Show signing secret):
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(',').map(part => part.split('=')));
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds;
return fresh && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ''));
}
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance=300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return abs(time.time() - int(parts["t"])) <= tolerance and hmac.compare_digest(expected, parts.get("v1", ""))
How it works inside
A channel is a local mailbox in the company's own mail store (src/lib/server/channels/): its cases
are stored like synced email, so the case list, conversation, attachments, triggers, statuses,
assignment and tags work unchanged. Replies on channel cases are kept in the case and sent as
webhooks instead of email (outbox/deliver.ts → channels/replies.ts); status and assignee changes
come from the case event log (channels/events.ts). The platform routes /api/v1 by the company
part of the key and never sees the secret; the company process checks it against the stored hash.