Generic channel API
Bridge any source into fikadesk over the api channel and its HMAC signing contract.
The api channel is the one you build against when you want a source fikadesk does not ship an adapter for. It is a signed HTTP door: your bridge posts inbound messages, and fikadesk delivers replies to a webhook URL you set. One channel per source.
The channel's secret is sealed at rest with AES-256-GCM under a subkey of FIKADESK_CHANNELS_SECRET, bound to its tenant and channel, so a row copied across either boundary never opens. An instance without FIKADESK_CHANNELS_SECRET cannot create an api channel.
Create a channel
You pick the webhook_secret at creation. It must be 16 to 256 characters. The secret is never answered back on the wire after this; only rotation returns a new one.
curl -X POST http://127.0.0.1/api/channels \
-H 'content-type: application/json' \
-H 'x-fikadesk-workspace: <workspace-id>' \
-H 'authorization: Bearer fk_<key>' \
-d '{"kind":"api","name":"Zapier","default_team_id":null,"webhook_secret":"<16-256 characters>"}'The channel starts receive-only. Set its webhook_url to receive replies:
curl -X PUT http://127.0.0.1/api/channels/<channel-id> \
-H 'content-type: application/json' \
-H 'x-fikadesk-workspace: <workspace-id>' \
-H 'authorization: Bearer fk_<key>' \
-d '{"webhook_url":"https://bridge.example.com/fikadesk"}'Inbound: sending messages in
Post to POST /api/channels/{id}/inbound. Sign the raw body with HMAC-SHA256 using your webhook_secret, and send the hex digest under x-fikadesk-signature plus a unix-seconds timestamp under x-fikadesk-timestamp. The timestamp must be within five minutes of the api's clock, which bounds replay.
The body is channelInboundRequest. external_id is your own thread id, unique per channel, and it is the dedup key: a redelivery of a message already filed answers 202 and files nothing. contact.external_id, email and phone are asserted by you, never proven, so they find or fill the contact rather than verify an identity.
{
"external_id": "ticket-4291",
"contact": {
"external_id": "cus_998",
"name": "Ada Lovelace",
"email": "[email protected]",
"phone": null
},
"content": "Where is my order?",
"attachments": [],
"at": "2026-08-31T09:41:00Z"
}import { createHmac } from "node:crypto";
const secret = "<the webhook_secret you registered>";
const body = JSON.stringify({ external_id: "ticket-4291", contact: { email: "[email protected]" }, content: "Where is my order?" });
const timestamp = String(Math.floor(Date.now() / 1000));
const signature = createHmac("sha256", secret).update(body).digest("hex");
const response = await fetch("https://<your-host>/api/channels/<channel-id>/inbound", {
method: "POST",
headers: {
"content-type": "application/json",
"x-fikadesk-signature": signature,
"x-fikadesk-timestamp": timestamp,
},
body,
});A body that is not application/json, a channel id that names nothing, and a signature that does not verify all answer the same 401 with no body, so a caller learns nothing about which check failed.
Outbound: receiving replies
When a teammate replies, fikadesk posts to your webhook_url. The body is the domain event envelope, seq included so a gap on your side means fetch events since. The signature is the same HMAC-SHA256 hex under x-fikadesk-signature, but with no timestamp header: replay protection on this direction is the seq dedup, not a clock check.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: Buffer, signatureHeader: string | null, secret: string): boolean {
if (signatureHeader === null) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest();
const provided = Buffer.from(signatureHeader, "hex");
return expected.length === provided.length && timingSafeEqual(expected, provided);
}Rotate the secret
POST /api/channels/{id}/rotate-secret mints a new webhook_secret and returns it once. The old one keeps verifying for a grace period, so you can roll a new secret across your bridge without a hard cutover.