Custom Connections
Connect any external system to Link AI with signed inbound events and outbound REST actions, with no native connector required.
Overview
A Custom Connection is a generic, two-way connector between any external system and Link AI. Your website, mobile app, ERP, POS terminal, IoT device, or internal dashboard can talk to Link AI through a single integration pattern, without waiting for a native connector.
Each connection has two channels:
- Inbound events: Your system POSTs signed events into Link AI. Those events trigger Workflows (save records, create Monday.com items, notify Ally, and more).
- Outbound actions: You expose plain REST endpoints on your system. Link AI's personal assistant Ally calls them as tools (for example, "remove offer X and add this new offer"). Link AI signs every outbound call so you can verify it before acting.
Configure a Custom Connection in the Link AI dashboard under Settings → Integrations → Custom API. There you set a name and base URL, copy your inbound webhook URL and signing secret, and define the actions and event types Ally and your workflows should know about.
Bulk setup: Have your IDE agent export a JSON configuration file (see IDE JSON export spec) and paste it via Import JSON. Faster than creating each action and event manually.
Why use it
- No native connector required. Connect any system that can send an HTTP request or expose an endpoint, with no waiting on a built-in integration.
- Two-way out of the box. Receive real-time events and let Ally take authenticated actions, from one configuration.
- Automate with Workflows. Every inbound event can trigger a Link AI workflow: save records, create Monday.com items, send email/Slack, branch on conditions, or hand off to Ally.
- Conversational control. Staff can manage your system in plain language ("take down this offer, publish that one") from chat or WhatsApp. Ally calls your API for them.
- Secure by design. Every request is HMAC-signed in both directions with a per-connection secret, plus replay protection. You keep full control of your data and APIs.
- One integration, many uses. The same connection powers forms, admin actions, notifications, and ops tooling. Reuse it instead of building point integrations per feature.
Example: bank website
Imagine a regional bank that wants Ally to manage promotional offers on its public website:
- When a visitor submits a "Request more info" form, the bank's CMS POSTs a
form.submittedevent to Link AI. A workflow creates a Monday.com lead, saves the contact, and sends the account manager a summary. - When a banker asks Ally in chat to "swap the homepage hero offer for the new 4.5% CD rate," Ally calls the bank's outbound
update_homepage_offeraction. The bank's API verifies Link AI's signature, updates the CMS, and returns the new offer ID.
The bank keeps full control of its CMS and APIs; Link AI handles orchestration, conversation, and automation.
Other use cases
- Point of sale: Send
order.completedorrefund.issuedevents; let Ally look up inventory or apply a manager override through your POS API. - ERP / inventory: Stream stock-level or shipment events inbound; expose actions like
reserve_stockorcreate_purchase_orderoutbound. - IoT / devices: POST sensor alerts (
device.offline,threshold.exceeded); let Ally acknowledge or schedule maintenance via your device-management API. - Internal apps: Connect a custom admin dashboard or ops tool without building a separate Link AI integration for each screen.
- Catch-all webhook: Forward any HTTP event source (form builder, legacy CRM, custom script) into Link AI workflows with one signed endpoint.
Concepts
| Term | Description |
|---|---|
| Connection | A named integration scoped to your Link AI workspace. Holds the base URL, inbound token, signing secret, actions, and event types. |
| Inbound token | An opaque token embedded in your inbound webhook URL. Link AI uses it to resolve which workspace and connection should receive the event. Treat the full URL as a credential. |
| Signing secret | A shared secret (prefixed whsec_ in the dashboard) used to HMAC-sign traffic in both directions. Never expose it in client-side code or public repositories. |
| Actions | Outbound REST endpoints you define (name, tool name, description, HTTP method, path, parameters). Ally invokes enabled actions as tools during chat. |
| Event types | Declared inbound event names (for example form.submitted, order.shipped) with optional labels and sample payloads. They document your integration and help the workflow builder suggest variables. You may POST any event string; declared types are recommended for consistency. |
Dashboard reference: every field explained
Open Settings → Integrations → Custom API. Everything below maps one-to-one to what you see there.
Step 1: Create a connection
Click Add connection, or use Import JSON to paste a configuration file from your IDE agent (see IDE JSON export spec below). Manual create asks for:
| Field | Required | What it means | Example |
|---|---|---|---|
| Name | Yes | A label for this connection, shown in the list. Use the system it connects to. | Production website |
| Base URL | Optional* | The root URL of your API. Every outbound action's Path is appended to this. Required only if you want Ally to call your endpoints (outbound). Leave blank if you only need inbound events. | https://api.yourbank.com |
| Log inbound events | Optional | When on, Link AI stores a copy of each inbound event it receives (useful for debugging/auditing). Off by default. | Off |
On save, Link AI generates your inbound webhook URL and signing secret automatically. You never enter these yourself.
Step 2: Overview tab
After creating (or by clicking Manage on a connection), the Overview tab shows:
| Field | Editable | What it means |
|---|---|---|
| Name | Yes | Same label as above; edit any time. |
| Base URL | Yes | Edit your API root; affects all outbound actions. |
| Log inbound events | Yes | Toggle event logging on/off. |
| Enable connection | Yes | Master on/off switch (also on the list row). When off, inbound events are rejected with 403 and Ally loses access to this connection's actions. |
| Inbound webhook URL | Read-only | The URL your system POSTs events to: …/api/connect/<inbound token>/events. Copy it with the copy button. Treat it as a credential. |
| Signing secret | Read-only | The shared HMAC secret (whsec_…). Click to reveal/copy. Store it server-side only; it signs traffic in both directions. |
Step 3: Actions tab (what Ally can do)
Each row is one REST endpoint Ally may call. Click Add action:
| Field | Required | What it means | Example |
|---|---|---|---|
| Name | Yes | Human-readable label for the action. | Update homepage offer |
| Tool name | Yes | The function identifier Ally uses internally. Lowercase snake_case; auto-suggested from the name. | update_homepage_offer |
| Description | Yes | The single most important field: it tells Ally when and how to use the tool. Be specific about the trigger phrasing. | Replace the homepage hero offer. Use when staff ask to swap, change, or feature a different offer on the homepage. |
| Method | Yes | HTTP method Link AI uses for the call. | POST |
| Path | Yes | Appended to the connection Base URL to form the full request URL. | /api/v1/offers/homepage |
| Headers | Optional | Extra request headers (key/value). Use {{ENV_VAR}} to inject a server-side environment variable. Handy for an extra API key. Values are resolved on Link AI's server, never exposed to the model. | Authorization: Bearer {{BANK_API_KEY}} |
| Parameters | Optional | The inputs Ally provides when calling. Sent as the JSON body (or query string for GET). Each parameter has its own fields (below). | See below |
| Response mapping | Optional | Alias nested fields from your JSON response so Ally sees them cleanly: alias → dot-path. | offerId → data.offer.id |
Each Parameter has:
| Sub-field | What it means | Example |
|---|---|---|
| Name | The JSON key Ally sends. snake_case. | rate |
| Type | string, number, boolean, or object. | number |
| Description | Tells Ally what to put here. | The interest rate to display, e.g. 4.5 |
| Required | If on, Ally must supply it before calling. | On |
Per row you also get an Enable toggle (turn a tool on/off without deleting it) and a Test button (sends a real signed request and shows the status, timing, and response).
Step 4: Events tab (what your system sends in)
Declaring events is optional. You can POST any event string. Declaring them documents your integration and powers {{field}} suggestions in the workflow builder. Click Add event:
| Field | Required | What it means | Example |
|---|---|---|---|
| Event name | Yes | The exact string you put in the event field of your inbound POST. | form.submitted |
| Label | Optional | Friendly name shown in the dashboard. | Contact form submitted |
| Sample payload | Optional | Example JSON of the data you send. Documents the fields your team and workflows can reference as {{fieldName}}. Must be valid JSON. | { "name": "Jane", "email": "jane@x.com" } |
Channel 1: Send events into Link AI (inbound)
Use the inbound channel when something happens in your system and you want Link AI to react automatically.
Endpoint
POST https://app.getlinkai.com/api/connect/{INBOUND_TOKEN}/events
Copy the exact inbound webhook URL from your connection's Overview tab in the dashboard. The host above is illustrative; your workspace may use a custom domain or preview URL.
Request body
Send JSON with a top-level event string and a data object containing arbitrary fields:
{
"event": "form.submitted",
"data": {
"name": "Jane Doe",
"email": "jane.doe@example.com",
"product": "Premium Checking",
"message": "I'd like more information about your rates."
}
}
The event value is required. Link AI forwards data fields into the workflow trigger payload (see What happens next).
Required headers
| Header | Value |
|---|---|
Content-Type | application/json |
x-linkai-timestamp | Current time in milliseconds since Unix epoch, as a string (for example "1718123456789") |
x-linkai-signature | sha256= followed by a lowercase hex HMAC digest (see below) |
Signing algorithm
Link AI verifies inbound requests with this exact scheme:
- Let
timestampbe the string you send inx-linkai-timestamp. - Let
rawBodybe the exact raw JSON bytes of the request body (the same string you pass to fetch / curl). - Compute
digest = HMAC-SHA256(key = signingSecret, message = "${timestamp}.${rawBody}")and hex-encode the result. - Set
x-linkai-signatureto the literal stringsha256=+ digest.
The signed message is the timestamp, a literal dot (.), then the raw body with no extra whitespace or re-serialization. If you build JSON in code, sign the same string you send on the wire.
Replay protection: Link AI rejects requests whose timestamp is more than 5 minutes away from server time. Always generate a fresh timestamp per request.
Responses
| Status | Meaning |
|---|---|
200 | { "ok": true }: event accepted |
400 | Invalid JSON or missing event |
401 | Missing, invalid, or stale signature |
403 | Connection disabled |
404 | Unknown inbound token |
Node.js example (inbound)
Uses built-in crypto and fetch (Node 18+):
import { createHmac } from 'crypto';
const INBOUND_URL = process.env.LINKAI_INBOUND_URL; // copy from dashboard
const SIGNING_SECRET = process.env.LINKAI_SIGNING_SECRET;
const payload = {
event: 'form.submitted',
data: {
name: 'Jane Doe',
email: 'jane.doe@example.com',
product: 'Premium Checking',
},
};
const rawBody = JSON.stringify(payload);
const timestamp = Date.now().toString();
const digest = createHmac('sha256', SIGNING_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const signature = `sha256=${digest}`;
const response = await fetch(INBOUND_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-linkai-timestamp': timestamp,
'x-linkai-signature': signature,
},
body: rawBody,
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Link AI rejected event: ${response.status} ${error}`);
}
console.log(await response.json()); // { ok: true }
PHP example (inbound)
Common on CMS and banking sites:
<?php
$inboundUrl = getenv('LINKAI_INBOUND_URL');
$signingSecret = getenv('LINKAI_SIGNING_SECRET');
$payload = [
'event' => 'form.submitted',
'data' => [
'name' => 'Jane Doe',
'email' => 'jane.doe@example.com',
'product' => 'Premium Checking',
],
];
$rawBody = json_encode($payload, JSON_UNESCAPED_SLASHES);
$timestamp = (string) round(microtime(true) * 1000);
$message = $timestamp . '.' . $rawBody;
$digest = hash_hmac('sha256', $message, $signingSecret);
$signature = 'sha256=' . $digest;
$ch = curl_init($inboundUrl);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $rawBody,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'x-linkai-timestamp: ' . $timestamp,
'x-linkai-signature: ' . $signature,
],
CURLOPT_RETURNTRANSFER => true,
]);
$responseBody = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($statusCode !== 200) {
throw new RuntimeException("Link AI rejected event: {$statusCode} {$responseBody}");
}
echo $responseBody; // {"ok":true}
What happens next
After signature verification, Link AI emits a workflow trigger:
- Trigger type:
external.event(labeled Custom Connection Event in the workflow builder) - Payload shape:
{ eventName, connectionId, ...yourDataFields }
The event string from your POST becomes eventName. Fields inside data are merged at the top level of the trigger payload.
In the Workflow builder:
- Create a workflow with trigger Custom Connection Event.
- Filter or branch on Event Name (for example
form.submitted). - Reference payload fields in steps with
{{fieldName}}, for example{{email}},{{product}},{{connectionId}}.
You can chain actions such as creating CRM records, sending email, posting to Slack, or handing off to Ally.
Channel 2: Let Ally call your system (outbound actions)
Use the outbound channel when Ally should do something in your system during a conversation.
Define actions in the dashboard
On your connection's Actions tab, add one row per endpoint Ally may call:
| Field | Purpose |
|---|---|
| Name | Human-readable label (for example "Update homepage offer") |
| Tool name | Snake-case identifier Ally uses internally (for example update_homepage_offer) |
| Description | Tells Ally when and how to use the tool. Be specific |
| Method | GET, POST, PUT, PATCH, or DELETE |
| Path | Appended to the connection Base URL (for example /api/v1/offers/homepage) |
| Headers | Optional extra request headers (key/value). Use {{ENV_VAR}} for server-side secrets |
| Parameters | Name, type, description, and required flag for each argument Ally may send |
| Response mapping | Optional dot-path aliases from your JSON response (for example offerId → data.id) |
Link AI builds the request URL as baseUrl + path, normalizing slashes. Example: base https://api.yourbank.com + path /offers/homepage → https://api.yourbank.com/offers/homepage.
Outbound signing (verify on your server)
Link AI signs every outbound request with the same scheme as inbound:
| Header | Value |
|---|---|
Content-Type | application/json (for methods with a body) |
x-linkai-timestamp | Milliseconds since epoch, as a string |
x-linkai-signature | sha256= + hex HMAC-SHA256 of ${timestamp}.${requestBodyString} |
For GET requests, requestBodyString is empty. The signed message is ${timestamp}. (timestamp, dot, nothing else).
Your endpoint must verify the signature with your connection's signing secret before performing any side effect. Reject missing, invalid, or stale (> 5 minutes) timestamps.
Parameters and responses
- POST / PUT / PATCH / DELETE: Parameters you define become the JSON request body Ally sends.
- GET: The signed body is empty. Include fixed query parameters in the action path if needed (for example
/offers?status=active).
Return JSON from your API. Non-JSON responses are wrapped as { "result": "<text>" }. Use Response mapping in the dashboard to expose nested fields to Ally (for example map data.offer.id to offerId).
Test any action from the dashboard with the Test button; Link AI performs a signed request and shows status, timing, and response body.
Node.js / Express example (outbound verification)
This example verifies Link AI's signature with constant-time comparison, then creates an offer:
import express from 'express';
import { createHmac, timingSafeEqual } from 'crypto';
const app = express();
const LINKAI_SIGNING_SECRET = process.env.LINKAI_SIGNING_SECRET;
const MAX_SKEW_MS = 5 * 60 * 1000;
// Preserve raw bytes for signature verification
app.use('/api/linkai', express.raw({ type: 'application/json' }));
function verifyLinkAIRequest(req) {
const timestamp = req.headers['x-linkai-timestamp'];
const signatureHeader = req.headers['x-linkai-signature'];
if (!timestamp || !signatureHeader) {
return { ok: false, status: 401, body: { error: 'missing signature' } };
}
if (Math.abs(Date.now() - Number(timestamp)) > MAX_SKEW_MS) {
return { ok: false, status: 401, body: { error: 'stale request' } };
}
const rawBody =
req.method === 'GET' ? '' : req.body.toString('utf8');
const expected =
'sha256=' +
createHmac('sha256', LINKAI_SIGNING_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const expectedBuf = Buffer.from(expected, 'utf8');
const gotBuf = Buffer.from(String(signatureHeader), 'utf8');
try {
if (
expectedBuf.length !== gotBuf.length ||
!timingSafeEqual(expectedBuf, gotBuf)
) {
return { ok: false, status: 401, body: { error: 'invalid signature' } };
}
} catch {
return { ok: false, status: 401, body: { error: 'invalid signature' } };
}
const parsed = rawBody ? JSON.parse(rawBody) : {};
return { ok: true, body: parsed };
}
app.post('/api/linkai/offers', (req, res) => {
const verified = verifyLinkAIRequest(req);
if (!verified.ok) {
return res.status(verified.status).json(verified.body);
}
const { offerId, title, rate, removeOfferId } = verified.body;
// Your business logic: example swap homepage offers
if (removeOfferId) {
// retire old offer
}
const newOffer = {
id: offerId ?? `offer_${Date.now()}`,
title,
rate,
updatedAt: new Date().toISOString(),
};
// persist to CMS / database...
return res.json({
success: true,
data: { offer: newOffer },
});
});
app.listen(3000, () => {
console.log('Listening for signed Link AI actions on port 3000');
});
IDE JSON export spec
Audience: AI coding agents (Cursor, Copilot, Claude Code, etc.) that implement a Custom Connection integration.
Goal: After analyzing the user's codebase or requirements, output one JSON object the user pastes into Link AI at Settings → Integrations → Custom API → Import JSON. That creates the connection, all outbound actions (Ally tools), and all inbound event types in one step.
Do not include signing secrets, inbound webhook URLs, or inbound tokens in the export JSON. Link AI generates those when the connection is created.
Output rules (agents must follow)
- Output only valid JSON. No markdown fences, no comments, no trailing commas, no prose before or after.
- Set
"version": 1at the root. - Include
"connection"when creating a new connection.connection.nameis required in that case. - Every action needs
name,description, andpath.toolNameis optional (Link AI derives snake_case from name if omitted). - Write
descriptionas instructions for Ally: when staff would ask for this, what it does. This is the most important field for conversational use. - Use dot-notation for
eventName(e.g.form.submitted,order.shipped). Match the exact string the inbound sender will POST in theeventfield. - Put realistic field names in
samplePayload. Workflows reference them as{{fieldName}}. parameters[].typemust be exactly one of:string,number,boolean,object.methodmust be one of:GET,POST,PUT,PATCH,DELETE(defaultPOSTif omitted).- Optional action headers use
{{ENV_VAR_NAME}}syntax (resolved from Link AI server env at call time). Example:"Authorization": "Bearer {{BANK_API_KEY}}". pathis a static URL suffix. Do not put parameter placeholders in the path. Parameters are sent in the JSON body (or query string for GET).- Duplicate
toolNameoreventNameon import are skipped, not updated. Use unique names.
TypeScript shape (for agents)
type CustomConnectionExport = {
version: 1;
connection?: {
name: string; // required when creating a new connection
baseUrl?: string | null; // API root; action paths are appended
persistEvents?: boolean; // default false; store inbound payloads for debugging
};
actions?: Array<{
name: string;
toolName?: string; // snake_case; auto-generated from name if omitted
description: string; // WHEN Ally should call this; be specific
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string; // relative to connection.baseUrl
headers?: Record<string, string> | null;
parameters?: Array<{
name: string;
type: "string" | "number" | "boolean" | "object";
description: string;
required: boolean;
}>;
responseMapping?: Record<string, string> | null; // alias → dot-path in API response
isEnabled?: boolean; // default true
}>;
events?: Array<{
eventName: string; // matches inbound POST body "event" field
label?: string | null;
samplePayload?: unknown; // example of inbound POST body "data" object
}>;
};
Canonical example (copy this structure)
This is the exact shape the Link AI dashboard Import JSON dialog accepts:
{
"version": 1,
"connection": {
"name": "Bank Website",
"baseUrl": "https://api.yourbank.com",
"persistEvents": false
},
"actions": [
{
"name": "Update homepage offer",
"toolName": "update_homepage_offer",
"description": "Replace the homepage hero offer. Use when staff ask to swap, change, or feature a different offer on the homepage.",
"method": "POST",
"path": "/api/v1/offers/homepage",
"headers": {
"Authorization": "Bearer {{BANK_API_KEY}}"
},
"parameters": [
{
"name": "title",
"type": "string",
"description": "Offer headline shown on the homepage",
"required": true
},
{
"name": "rate",
"type": "number",
"description": "Interest rate to display (e.g. 4.5)",
"required": true
},
{
"name": "removeOfferId",
"type": "string",
"description": "ID of the current offer to retire before publishing the new one",
"required": false
}
],
"responseMapping": {
"offerId": "data.offer.id"
},
"isEnabled": true
},
{
"name": "Remove offer",
"toolName": "remove_offer",
"description": "Take an offer offline. Use when staff ask to remove, hide, or unpublish a specific offer.",
"method": "POST",
"path": "/api/v1/offers/remove",
"parameters": [
{
"name": "offerId",
"type": "string",
"description": "The offer ID to remove",
"required": true
}
],
"isEnabled": true
}
],
"events": [
{
"eventName": "form.submitted",
"label": "Contact form submitted",
"samplePayload": {
"name": "Jane Doe",
"email": "jane.doe@example.com",
"phone": "+1-555-0100",
"product": "Premium Checking"
}
},
{
"eventName": "offer.clicked",
"label": "Visitor clicked a promotional offer",
"samplePayload": {
"offerId": "offer_abc123",
"page": "/checking",
"sessionId": "sess_xyz"
}
}
]
}
Partial export shapes
Use these when importing into an existing connection from the Actions or Events tab.
Actions only: object wrapper or bare array:
{
"actions": [
{
"name": "Update homepage offer",
"description": "Replace the homepage hero offer.",
"method": "POST",
"path": "/api/v1/offers/homepage",
"parameters": []
}
]
}
Or a bare array: [ { "name": "...", "description": "...", "path": "..." } ]
Events only:
{
"events": [
{
"eventName": "form.submitted",
"label": "Contact form submitted",
"samplePayload": { "email": "user@example.com" }
}
]
}
Field reference
| Field | Required | Notes |
|---|---|---|
version | Recommended | Must be 1 |
connection.name | Yes (new connection) | Display name in dashboard |
connection.baseUrl | No | Required for outbound actions; omit trailing slash |
connection.persistEvents | No | Default false |
actions[].name | Yes | Human label |
actions[].toolName | No | Unique snake_case Ally tool id; derived from name if omitted |
actions[].description | Yes | Ally tool instructions. Describe trigger phrases and behavior. |
actions[].method | No | Default POST |
actions[].path | Yes | Relative path on baseUrl |
actions[].headers | No | Static strings or {{ENV_VAR}} placeholders |
actions[].parameters | No | JSON body fields Ally sends (query string for GET) |
actions[].responseMapping | No | Flatten nested API responses for Ally |
actions[].isEnabled | No | Default true |
events[].eventName | Yes | Exact inbound event string your sender POSTs |
events[].label | No | Dashboard display name |
events[].samplePayload | No | Example data object for workflow {{variables}} |
Agent prompt: export dashboard JSON
Give this prompt to your IDE agent before asking it to implement server code. Replace the bracketed placeholders:
You are configuring a Link AI Custom Connection integration.
TASK: Output ONLY a single JSON object for Link AI dashboard → Import JSON.
Do NOT write implementation code in this step. Do NOT wrap the JSON in markdown fences.
Context:
- System: [DESCRIBE THE APP, e.g. bank CMS, e-commerce admin, internal ERP]
- Base URL: [YOUR API ROOT, e.g. https://api.yourbank.com]
- Connection name: [DISPLAY NAME, e.g. Production website]
Outbound actions Ally should perform (REST endpoints you will build):
- [ACTION 1, e.g. update homepage hero offer]
- [ACTION 2, e.g. remove an offer by ID]
- [add more…]
Inbound events our system will POST to Link AI:
- [EVENT 1, e.g. contact form submitted]
- [EVENT 2, e.g. offer link clicked]
- [add more…]
Follow these rules exactly:
1. Root object with "version": 1
2. "connection": { "name", "baseUrl", "persistEvents": false }
3. "actions": array. One object per outbound endpoint with name, toolName (snake_case), description (WHEN Ally uses it), method, path, parameters (name/type/description/required), optional responseMapping
4. "events": array. One object per inbound event with eventName (dot notation), label, samplePayload (realistic field names for workflows)
5. descriptions must tell Ally when to use each tool in plain language
6. no secrets, no webhook URLs, no inbound tokens in the JSON
Output valid JSON only.
Agent prompt: export JSON after implementing endpoints
Use this when the agent already built the API and you want it to reverse-engineer the dashboard config from the code:
Read the Custom Connection endpoints and webhook senders in this codebase.
Output ONLY the Link AI dashboard import JSON (version 1) that registers:
- connection name and baseUrl from our config/env
- every outbound route Ally should call (path, method, body parameters, descriptions)
- every inbound event we POST (event name + sample payload shape)
Rules: valid JSON only, no markdown fences, no secrets. Match paths and parameter names exactly to what the code expects. Descriptions must explain when Ally should use each action.
Import target: Link AI → Settings → Integrations → Custom API → Import JSON
After export
- User pastes JSON into Import JSON in the Link AI dashboard.
- Dashboard shows a preview (connection fields, action count, event count).
- On import: connection is created or updated; new actions/events are added; duplicates skipped.
- User copies Inbound webhook URL and Signing secret from Overview, then implements signing using the prompts below.
Build it with your IDE agent
You can hand the integration work to an AI coding agent (Cursor, GitHub Copilot, Claude Code, etc.). Start with the IDE JSON export spec above so the agent outputs paste-ready dashboard JSON, then use the implementation prompts below for server code.
Integration contract (reference)
LINK AI CUSTOM CONNECTION: INTEGRATION CONTRACT
Signing (identical in both directions):
message = `${timestamp}.${rawBody}` // rawBody is "" for GET
digest = HMAC_SHA256(signingSecret, message) in lowercase hex
headers = {
"x-linkai-timestamp": timestamp, // ms since epoch, as a string
"x-linkai-signature": "sha256=" + digest
}
Replay window: reject if |now - timestamp| > 5 minutes.
Compare signatures in constant time.
INBOUND (your system -> Link AI):
POST {INBOUND_WEBHOOK_URL}
Content-Type: application/json
Body: { "event": "<event.name>", "data": { ...arbitrary fields } }
Sign the exact raw body you send. Success = HTTP 200 { "ok": true }.
OUTBOUND (Link AI -> your system):
Link AI calls {BASE_URL}{action.path} with the headers above.
Verify the signature BEFORE any side effect, then act and return JSON.
Defined action "parameters" arrive as the JSON body (or query for GET).
Secrets:
Keep signingSecret server-side only (env var / secrets manager).
Prompt: implement the inbound sender
Copy this to your IDE agent and replace {{YOUR_STACK}} (e.g. "Next.js API route", "Laravel", "Express", "WordPress plugin"):
Implement an outgoing webhook that notifies Link AI when an event happens in our app, using {{YOUR_STACK}}.
Requirements:
- Read two env vars: LINKAI_INBOUND_URL and LINKAI_SIGNING_SECRET.
- Build a JSON body: { "event": "<event name>", "data": { ...relevant fields } }.
- Serialize the body ONCE to a string (rawBody). Do not re-serialize after signing.
- timestamp = current time in milliseconds since epoch, as a string.
- signature = "sha256=" + HMAC_SHA256(LINKAI_SIGNING_SECRET, `${timestamp}.${rawBody}`) as lowercase hex.
- POST rawBody to LINKAI_INBOUND_URL with headers:
Content-Type: application/json
x-linkai-timestamp: <timestamp>
x-linkai-signature: <signature>
- Treat HTTP 200 { "ok": true } as success; log and surface non-200 responses.
- Send a fresh timestamp on every request (Link AI rejects anything older than 5 minutes).
- Wire it to fire on our real event (e.g. contact form submission). Start with a single event named "form.submitted".
- Put the signing in a small reusable helper. Never expose LINKAI_SIGNING_SECRET to the client/browser.
Prompt: implement the outbound action endpoints
Copy this to your IDE agent and replace {{YOUR_STACK}} and {{ACTIONS}} (the operations you want Ally to perform, e.g. "create_offer, remove_offer, update_homepage_hero"):
Implement HTTP endpoints that Link AI's assistant will call as tools, using {{YOUR_STACK}}.
Actions to support: {{ACTIONS}}.
Security (required on EVERY endpoint, before any side effect):
- Read LINKAI_SIGNING_SECRET from env.
- Capture the RAW request body bytes (do not let the framework parse-then-reserialize before verifying).
- Read headers x-linkai-timestamp and x-linkai-signature.
- Reject (401) if either header is missing.
- Reject (401) if |now - timestamp| > 5 minutes.
- Compute expected = "sha256=" + HMAC_SHA256(LINKAI_SIGNING_SECRET, `${timestamp}.${rawBody}`) (lowercase hex);
for GET, rawBody is "".
- Constant-time compare expected vs the signature header; reject (401) on mismatch.
- Only after verification, parse the JSON body and perform the action.
For each action:
- Pick a clear path (e.g. POST /api/linkai/offers). This is the "path" I will enter in the Link AI dashboard.
- The JSON body fields are the "parameters" I will declare in the dashboard; validate them.
- Perform the operation against our CMS/database.
- Return JSON describing the result (e.g. { "success": true, "data": { "id": "..." } }).
- Use status codes: 401 auth failure, 400 bad input, 404 missing resource, 500 only for real errors.
Provide a single shared verification middleware/helper and one handler per action. Paths and parameters must match the Link AI dashboard JSON you exported earlier (or re-export JSON from the code if not done yet).
After your agent finishes: if you have not already, paste the dashboard JSON via Import JSON, then use the Test button on each action to confirm signed requests succeed end to end.
Security best practices
- Keep the signing secret server-side only. Load it from environment variables or a secrets manager. Never embed it in browser JavaScript, mobile apps, or public Git repos.
- Always verify both signature and timestamp. Use constant-time comparison (
timingSafeEqualin Node,hash_equalsin PHP) to prevent timing attacks. - Use HTTPS everywhere. Both your inbound POSTs to Link AI and Link AI's outbound calls to your API should run over TLS.
- Scope what endpoints allow. Outbound actions should perform the smallest possible change. Avoid generic "run any query" tools.
- Return clear HTTP status codes. Use 401 for auth failures, 400 for bad input, 404 for missing resources, and 500 only for genuine server errors. This helps Ally and the dashboard Test button surface useful errors.
- Rotate secrets if exposed. Create a new connection or rotate the signing secret in the dashboard (when supported) and update your servers promptly.
- Treat the inbound webhook URL as a capability URL. Anyone with the URL could attempt forged requests; the HMAC signature is what makes it safe.
Quickstart: connect your website
- Create a connection. Open Settings → Integrations → Custom API. Fastest: have your IDE agent output dashboard JSON per the IDE JSON export spec, then Import JSON. Or click Add connection and fill fields manually.
- Copy credentials. On the connection Overview tab, copy the Inbound webhook URL and Signing secret into your server's environment variables.
- Implement an inbound sender. When a form is submitted (or another event occurs), POST signed JSON to the webhook URL using the Node.js or PHP example above. Event names must match what you declared in the import JSON.
- Event types. If you used Import JSON, events are already declared. Otherwise add them on the Events tab with sample payloads for workflow variable hints.
- Build a workflow. Create a workflow with trigger Custom Connection Event. Filter on
eventNameequalsform.submitted. Add steps (save contact, Monday.com item, email notification, etc.) using{{fieldName}}variables from the payload. - Outbound actions. If you used Import JSON, actions are already registered. Otherwise define them on the Actions tab. Descriptions tell Ally when to use each tool.
- Verify signatures on your API. Implement HMAC verification on every action route before making changes (see the Express example).
- Test from the dashboard. Use the Test button on each action to send a signed request and confirm your API responds correctly. Submit a test form on your site and confirm the workflow runs.
Once both channels work, Ally can react to real-time events from your website and take authenticated actions on your behalf.