helpr docs

Data Connectors

Connect your API endpoints to Luca so it can look up orders, check inventory, verify accounts, and more during live conversations.

Overview

Data Connectors let you connect your own API endpoints to Luca, the Helpr AI agent. When a visitor asks a question that requires live data — order status, account balance, stock availability — Luca calls your endpoint, reads the response, and answers the visitor in natural language.

Under the hood, Data Connectors use AI-powered function calling. Each connector you create becomes a tool that Luca can invoke during a conversation. You define the tool name, description, parameters, and the URL to call. Luca decides when to use it based on the conversation context.

Quick Start

  1. Go to Settings → AI Agent → Data Connectors in the Helpr dashboard
  2. Click Add connector
  3. Fill in the tool definition:
FieldExampleDescription
Nameorder_lookupUnique identifier (snake_case, max 64 chars)
DescriptionLook up an order by order number and return its status, tracking info, and delivery estimate.Tells Luca when and why to use this tool
Endpoint URLhttps://api.yourstore.com/helpr/order-lookupThe URL Helpr will call
MethodPOSTGET or POST
Requires IdentityOffWhen enabled, Helpr includes verified visitor identity

Define parameters

Add parameters using the visual builder in the modal. Each parameter maps to a JSON Schema property that Luca fills in from the conversation.

{
  "type": "object",
  "properties": {
    "order_number": {
      "type": "string",
      "description": "The order number, e.g. ORD-12345"
    }
  },
  "required": ["order_number"]
}
  1. Copy your Signing Secret from the Data Connectors tab (format: hlpr_wh_ + 48 hex chars)
  2. Deploy your endpoint and send a test request from the dashboard

Request Signing

Helpr signs every outbound request with HMAC-SHA256 so you can verify it originated from Helpr and has not been tampered with.

Headers

HeaderDescriptionExample
X-Helpr-TimestampUnix epoch seconds when the request was signed1714003200
X-Helpr-SignatureHMAC-SHA256 signature of the canonical stringsha256=a1b2c3...
Content-TypeAlways application/json for POST requestsapplication/json

Canonical string

The signature is computed over a canonical string built from four components joined by newlines:

{METHOD}\n{PATH}\n{TIMESTAMP}\n{SHA256_OF_BODY}
ComponentDescription
METHODHTTP method, uppercase (POST or GET)
PATHRequest path including query string (e.g. /helpr/order-lookup)
TIMESTAMPSame value as the X-Helpr-Timestamp header
SHA256_OF_BODYHex-encoded SHA-256 hash of the raw request body

Signature format

sha256= + HMAC-SHA256(canonical_string, signing_secret)

The HMAC is hex-encoded.

Verification steps

  1. Check the timestamp — reject requests where X-Helpr-Timestamp is more than 5 minutes from your server's current time
  2. Rebuild the canonical string from the incoming request
  3. Compute the expected HMAC using your signing secret
  4. Compare signatures using a constant-time comparison function to prevent timing attacks

Request Format

POST requests

The request body is JSON. Tool parameters are top-level keys:

{
  "order_number": "ORD-12345"
}

GET requests

Parameters are sent as query string parameters:

GET /helpr/check-stock?sku=WIDGET-42&warehouse=us-east

Identity Object

When a connector has Requires Identity enabled and the visitor is verified (via helpr.identify() with a resolved contact), Helpr includes a helpr_identity object:

{
  "order_number": "ORD-12345",
  "helpr_identity": {
    "email": "[email protected]",
    "name": "Jane Smith",
    "verified": true,
    "custom": { "plan": "enterprise", "account_id": "acc_123" }
  }
}
FieldTypeDescription
emailstringVisitor's verified email address
namestringVisitor's display name
verifiedbooleanAlways true when present
customobjectCustom data set via helpr.setCustom() or the API
If the visitor is not verified and the connector requires identity, Luca will not call the endpoint. Instead, it asks the visitor to identify themselves.

Visitor Object

Helpr automatically includes a helpr_visitor object with session context on every connector request, regardless of identity settings:

{
  "order_number": "ORD-12345",
  "helpr_visitor": {
    "current_url": "https://yoursite.com/products/widget",
    "country": "CA",
    "city": "Toronto",
    "timezone": "America/Toronto",
    "language": "en",
    "device": "desktop"
  }
}
FieldTypeDescription
current_urlstringThe page the visitor is currently on
countrystringTwo-letter country code (ISO 3166-1)
citystringVisitor's city (from IP geolocation)
timezonestringIANA timezone identifier
languagestringBrowser language code
devicestringDevice type: desktop, mobile, or tablet
All fields are optional and included only when available. The helpr_visitor object is always present (even without identity verification), so your endpoint can use it for location-aware responses like showing nearby store stock.

Response Format

Return JSON from your endpoint. Maximum response size is 4 KB — anything beyond that is truncated.

Standard response

Return any JSON object. Luca reads the data and formulates a natural-language answer for the visitor.

{
  "order_id": "ORD-12345",
  "status": "shipped",
  "tracking_number": "1Z999AA10123456784",
  "estimated_delivery": "2026-04-28"
}

Luca will say something like: “Your order ORD-12345 has shipped! The tracking number is 1Z999AA10123456784 and it's estimated to arrive on April 28th.”

Challenge-Response Protocol

If your endpoint needs to verify the visitor's identity before releasing data, return a helpr_challenge array instead of the data. Each item defines a question for Luca to ask:

{
  "helpr_challenge": [
    {
      "field": "shipping_zip",
      "prompt": "What is the shipping postal/zip code for this order?"
    },
    {
      "field": "last_four",
      "prompt": "What are the last 4 digits of the card used?",
      "type": "number"
    }
  ]
}
FieldTypeRequiredDescription
fieldstringYesKey name for the answer (returned in helpr_challenge_response)
promptstringYesThe question Luca asks the visitor
typestringNoExpected answer type: string (default), number, email

Luca asks the visitor naturally, collects answers, and retries with helpr_challenge_response:

{
  "order_number": "ORD-12345",
  "helpr_identity": { ... },
  "helpr_challenge_response": {
    "shipping_zip": "90210"
  }
}

Full flow

1. Visitor: "Where's my order ORD-12345?"
2. Luca calls your endpoint with { "order_number": "ORD-12345" }
3. Your endpoint returns helpr_challenge asking for shipping zip
4. Luca asks the visitor: "Could you share the zip code for the shipping address?"
5. Visitor: "90210"
6. Luca retries with helpr_challenge_response: { "shipping_zip": "90210" }
7. Your endpoint validates → returns order data (or 403 if wrong)

Your endpoint is stateless — it receives the full context (parameters, identity, and challenge answers) on every call.

Error Handling

Return an HTTP 4xx status code with an error or message field:

{ "error": "Order not found" }

Luca relays the error naturally: “I wasn't able to find that order number. Could you double-check it?”

Status CodeUsage
400Bad request / invalid parameters
401Identity required but not provided
403Challenge verification failed
404Resource not found
429Rate limited — Luca tells the visitor to try again later

Signature Verification

const crypto = require('crypto');

function verifyHelprSignature(req, secret) {
  const timestamp = req.headers['x-helpr-timestamp'];
  const signature = req.headers['x-helpr-signature'];

  // Reject if older than 5 minutes
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return false;
  }

  const body = JSON.stringify(req.body);
  const path = req.originalUrl || req.url;
  const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
  const canonical = `POST\n${path}\n${timestamp}\n${bodyHash}`;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret).update(canonical).digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature), Buffer.from(expected)
  );
}
import hashlib, hmac, time

def verify_helpr_signature(request, secret: str) -> bool:
    timestamp = request.headers.get('X-Helpr-Timestamp', '')
    signature = request.headers.get('X-Helpr-Signature', '')

    if abs(time.time() - int(timestamp)) > 300:
        return False

    body = request.get_data(as_text=True)
    path = request.path
    body_hash = hashlib.sha256(body.encode()).hexdigest()
    canonical = f"POST\n{path}\n{timestamp}\n{body_hash}"
    expected = 'sha256=' + hmac.new(
        secret.encode(), canonical.encode(), hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(signature, expected)
function verifyHelprSignature(string $secret): bool {
    $timestamp = $_SERVER['HTTP_X_HELPR_TIMESTAMP'] ?? '';
    $signature = $_SERVER['HTTP_X_HELPR_SIGNATURE'] ?? '';

    if (abs(time() - (int) $timestamp) > 300) return false;

    $body = file_get_contents('php://input');
    $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
    $canonical = "POST\n{$path}\n{$timestamp}\n" . hash('sha256', $body);
    $expected = 'sha256=' . hash_hmac('sha256', $canonical, $secret);

    return hash_equals($expected, $signature);
}
require 'openssl'

def verify_helpr_signature(request, secret)
  timestamp = request.headers['X-Helpr-Timestamp']
  signature = request.headers['X-Helpr-Signature']

  return false if (Time.now.to_i - timestamp.to_i).abs > 300

  body = request.body.read
  path = request.path
  body_hash = OpenSSL::Digest::SHA256.hexdigest(body)
  canonical = "POST\n#{path}\n#{timestamp}\n#{body_hash}"
  expected = 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', secret, canonical)

  Rack::Utils.secure_compare(expected, signature)
end
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "math"
    "net/http"
    "strconv"
    "time"
)

func verifyHelprSignature(r *http.Request, secret string) bool {
    timestamp := r.Header.Get("X-Helpr-Timestamp")
    signature := r.Header.Get("X-Helpr-Signature")

    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
        return false
    }

    bodyHash := sha256.Sum256(body)
    canonical := fmt.Sprintf("POST\n%s\n%s\n%x", r.URL.Path, timestamp, bodyHash)

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(canonical))
    expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))

    return hmac.Equal([]byte(signature), []byte(expected))
}

Full Example: Order Lookup

A complete endpoint with HMAC verification, identity checks, challenge-response, and data retrieval.

const express = require('express');
const crypto = require('crypto');
const app = express();

app.use(express.json());

const HELPR_SECRET = process.env.HELPR_WEBHOOK_SECRET;

function verifyHelpr(req, res, next) {
  const timestamp = req.headers['x-helpr-timestamp'];
  const signature = req.headers['x-helpr-signature'];

  if (!timestamp || !signature) {
    return res.status(401).json({ error: 'Missing signature' });
  }
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.status(401).json({ error: 'Request too old' });
  }

  const body = JSON.stringify(req.body);
  const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
  const canonical = `POST\n${req.originalUrl}\n${timestamp}\n${bodyHash}`;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', HELPR_SECRET).update(canonical).digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  next();
}

app.post('/helpr/order-lookup', verifyHelpr, async (req, res) => {
  const { order_number, helpr_identity, helpr_challenge_response } = req.body;

  const order = await db.orders.findOne({ orderNumber: order_number });
  if (!order) {
    return res.status(404).json({ error: 'Order not found' });
  }

  if (!helpr_identity) {
    return res.status(401).json({ error: 'Identity required' });
  }

  // Challenge if no verification yet
  if (!helpr_challenge_response) {
    return res.json({
      helpr_challenge: [{
        field: 'shipping_zip',
        prompt: 'What is the shipping zip code for this order?'
      }]
    });
  }

  // Validate the answer
  if (helpr_challenge_response.shipping_zip !== order.shippingAddress.zip) {
    return res.status(403).json({ error: 'Zip code does not match' });
  }

  res.json({
    order_id: order.orderNumber,
    status: order.status,
    tracking_number: order.trackingNumber || null,
    estimated_delivery: order.estimatedDelivery || null,
    items: order.items.map(i => ({ name: i.name, qty: i.quantity })),
    total: '$' + order.total.toFixed(2)
  });
});

app.listen(3000);
import hashlib, hmac, json, time
from flask import Flask, request, jsonify

app = Flask(__name__)
HELPR_SECRET = os.environ['HELPR_WEBHOOK_SECRET']

def verify_helpr(f):
    def wrapper(*args, **kwargs):
        timestamp = request.headers.get('X-Helpr-Timestamp', '')
        signature = request.headers.get('X-Helpr-Signature', '')

        if abs(time.time() - int(timestamp)) > 300:
            return jsonify(error='Request too old'), 401

        body = request.get_data(as_text=True)
        body_hash = hashlib.sha256(body.encode()).hexdigest()
        canonical = f"POST\n{request.path}\n{timestamp}\n{body_hash}"
        expected = 'sha256=' + hmac.new(
            HELPR_SECRET.encode(), canonical.encode(), hashlib.sha256
        ).hexdigest()

        if not hmac.compare_digest(signature, expected):
            return jsonify(error='Invalid signature'), 401

        return f(*args, **kwargs)
    wrapper.__name__ = f.__name__
    return wrapper

@app.route('/helpr/order-lookup', methods=['POST'])
@verify_helpr
def order_lookup():
    data = request.json
    order_number = data.get('order_number')
    identity = data.get('helpr_identity')
    challenge_resp = data.get('helpr_challenge_response')

    order = db.orders.find_one({'order_number': order_number})
    if not order:
        return jsonify(error='Order not found'), 404

    if not identity:
        return jsonify(error='Identity required'), 401

    # Challenge if no verification yet
    if not challenge_resp:
        return jsonify(helpr_challenge=[{
            'field': 'shipping_zip',
            'prompt': 'What is the shipping zip code for this order?'
        }])

    # Validate the answer
    if challenge_resp.get('shipping_zip') != order['shipping_zip']:
        return jsonify(error='Zip code does not match'), 403

    return jsonify(
        order_id=order['order_number'],
        status=order['status'],
        tracking_number=order.get('tracking_number'),
        estimated_delivery=order.get('estimated_delivery'),
        total=f"${order['total']:.2f}"
    )
<?php
$secret = $_ENV['HELPR_WEBHOOK_SECRET'];

// Verify signature
$timestamp = $_SERVER['HTTP_X_HELPR_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_HELPR_SIGNATURE'] ?? '';

if (abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    echo json_encode(['error' => 'Request too old']);
    exit;
}

$rawBody = file_get_contents('php://input');
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$canonical = "POST\n{$path}\n{$timestamp}\n" . hash('sha256', $rawBody);
$expected = 'sha256=' . hash_hmac('sha256', $canonical, $secret);

if (!hash_equals($expected, $signature)) {
    http_response_code(401);
    echo json_encode(['error' => 'Invalid signature']);
    exit;
}

$data = json_decode($rawBody, true);
$orderNumber = $data['order_number'] ?? '';
$identity = $data['helpr_identity'] ?? null;
$challengeResp = $data['helpr_challenge_response'] ?? null;

$order = $db->orders->findOne(['order_number' => $orderNumber]);
if (!$order) {
    http_response_code(404);
    echo json_encode(['error' => 'Order not found']);
    exit;
}

if (!$identity) {
    http_response_code(401);
    echo json_encode(['error' => 'Identity required']);
    exit;
}

// Challenge if no verification yet
if (!$challengeResp) {
    echo json_encode(['helpr_challenge' => [[
        'field' => 'shipping_zip',
        'prompt' => 'What is the shipping zip code for this order?',
    ]]]);
    exit;
}

// Validate the answer
if (($challengeResp['shipping_zip'] ?? '') !== $order['shipping_zip']) {
    http_response_code(403);
    echo json_encode(['error' => 'Zip code does not match']);
    exit;
}

echo json_encode([
    'order_id' => $order['order_number'],
    'status' => $order['status'],
    'tracking_number' => $order['tracking_number'] ?? null,
    'estimated_delivery' => $order['estimated_delivery'] ?? null,
    'total' => '$' . number_format($order['total'], 2),
]);
require 'sinatra'
require 'json'
require 'openssl'

HELPR_SECRET = ENV['HELPR_WEBHOOK_SECRET']

before '/helpr/*' do
  request.body.rewind
  @raw_body = request.body.read
  timestamp = request.env['HTTP_X_HELPR_TIMESTAMP']
  signature = request.env['HTTP_X_HELPR_SIGNATURE']

  if (Time.now.to_i - timestamp.to_i).abs > 300
    halt 401, { error: 'Request too old' }.to_json
  end

  body_hash = OpenSSL::Digest::SHA256.hexdigest(@raw_body)
  canonical = "POST\n#{request.path}\n#{timestamp}\n#{body_hash}"
  expected = 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', HELPR_SECRET, canonical)

  unless Rack::Utils.secure_compare(expected, signature)
    halt 401, { error: 'Invalid signature' }.to_json
  end

  @data = JSON.parse(@raw_body)
end

post '/helpr/order-lookup' do
  content_type :json
  order = DB[:orders].where(order_number: @data['order_number']).first
  halt 404, { error: 'Order not found' }.to_json unless order

  identity = @data['helpr_identity']
  halt 401, { error: 'Identity required' }.to_json unless identity

  challenge_resp = @data['helpr_challenge_response']

  # Challenge if no verification yet
  unless challenge_resp
    return { helpr_challenge: [{
      field: 'shipping_zip',
      prompt: 'What is the shipping zip code for this order?'
    }] }.to_json
  end

  # Validate the answer
  if challenge_resp['shipping_zip'] != order[:shipping_zip]
    halt 403, { error: 'Zip code does not match' }.to_json
  end

  {
    order_id: order[:order_number],
    status: order[:status],
    tracking_number: order[:tracking_number],
    estimated_delivery: order[:estimated_delivery],
    total: "$#{'%.2f' % order[:total]}"
  }.to_json
end

Best Practices

Tool naming and descriptions

Luca uses the tool name and description to decide when to call your endpoint. Write descriptions as if explaining to a support agent what this tool does and when to use it. Be specific:

Good: “Look up an order by order number and return its current status, tracking info, and estimated delivery date.”
Bad: “Get order info.”

Keep responses small

The 4 KB limit exists because the response is injected into Luca's context window. Return only the data Luca needs to answer the visitor. Omit internal IDs, audit logs, and raw database fields.

Use challenge-response for PII

Never return sensitive data (addresses, payment details, order history) without verifying the visitor. Use helpr_challenge to ask a verification question even if helpr_identity is present — the identity confirms who they claim to be, the challenge confirms they own the resource.

Set appropriate timeouts

Your endpoint must respond within 10 seconds. If your data source is slow, consider caching or returning a simplified response. Luca shows a typing indicator while waiting.

Return descriptive errors

Error messages in the error field are shown to the visitor (paraphrased by Luca). “No order found with that number” is better than “404” or “null reference exception.”

Idempotent endpoints

Helpr may retry a request if the first attempt times out. Your endpoint should be safe to call multiple times with the same parameters.

HTTPS only

Helpr only calls endpoints over HTTPS. Plain HTTP URLs are rejected at configuration time.