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 signed<unix_epoch_seconds>
X-Helpr-SignatureHMAC-SHA256 signature of the timestamp and exact raw request body bytessha256=a1b2c3...
Content-TypeAlways application/json for POST requestsapplication/json

Signed bytes and signature format

This Data Connector callback contract signs the timestamp, a literal ASCII period, and the exact request body bytes:

signed_payload = ASCII(X-Helpr-Timestamp) + "." + raw_request_body_bytes
X-Helpr-Signature = "sha256=" + lowercase_hex(HMAC-SHA256(signing_secret, signed_payload))

Do not parse, reformat, or re-serialize the JSON before verification: whitespace, escaping, and key order are part of the signed body. Capture the raw bytes before a framework body parser consumes them. The HTTP method and URL path are not part of this Data Connector signature. Platform API authentication and event webhooks use separate signing contracts documented in their own references.

Verification steps

  1. Validate the headers — require a decimal Unix timestamp and a sha256= value containing 64 lowercase hexadecimal characters
  2. Check the timestamp — reject requests more than 5 minutes in the past or future
  3. Build the signed payload from the timestamp header, a period, and the unmodified raw body bytes
  4. Compute the expected HMAC using your Data Connector signing secret
  5. 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'];
  const rawBody = req.rawBody; // Capture as a Buffer in the JSON parser's verify hook.

  if (!/^[1-9][0-9]*$/.test(timestamp || '') ||
      !/^sha256=[a-f0-9]{64}$/.test(signature || '') ||
      !Buffer.isBuffer(rawBody)) {
    return false;
  }
  const signedAt = Number(timestamp);
  if (!Number.isSafeInteger(signedAt) ||
      Math.abs(Math.floor(Date.now() / 1000) - signedAt) > 300) return false;

  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(timestamp + '.', 'ascii')
    .update(rawBody)
    .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 (not timestamp or len(timestamp) > 12
            or not timestamp.isascii() or not timestamp.isdecimal()
            or timestamp[0] == '0' or len(signature) != 71
            or not signature.startswith('sha256=')
            or any(c not in '0123456789abcdef' for c in signature[7:])):
        return False
    if abs(int(time.time()) - int(timestamp)) > 300:
        return False

    body = request.get_data(cache=True)  # bytes; do not decode or re-serialize
    signed_payload = timestamp.encode('ascii') + b'.' + body
    expected = 'sha256=' + hmac.new(
        secret.encode(), signed_payload, 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 (!preg_match('/^[1-9][0-9]*$/D', $timestamp)
        || !preg_match('/^sha256=[a-f0-9]{64}$/D', $signature)
        || abs(time() - (int) $timestamp) > 300) return false;

    $body = file_get_contents('php://input');
    if ($body === false) return false;
    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $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 unless timestamp&.match?(/\A[1-9][0-9]*\z/)
  return false unless signature&.match?(/\Asha256=[a-f0-9]{64}\z/)
  return false if (Time.now.to_i - timestamp.to_i).abs > 300

  request.body.rewind
  body = request.body.read
  request.body.rewind
  signed_payload = timestamp.b + '.'.b + body.b
  expected = 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', secret, signed_payload)

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

import (
    "bytes"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "io"
    "net/http"
    "strconv"
    "strings"
    "time"
)

func verifyHelprSignature(r *http.Request, secret string) bool {
    timestamp := r.Header.Get("X-Helpr-Timestamp")
    signature := r.Header.Get("X-Helpr-Signature")
    if timestamp == "" || timestamp[0] == '0' ||
        strings.Trim(timestamp, "0123456789") != "" ||
        len(signature) != 71 || !strings.HasPrefix(signature, "sha256=") ||
        strings.Trim(signature[7:], "0123456789abcdef") != "" {
        return false
    }

    ts, err := strconv.ParseInt(timestamp, 10, 64)
    now := time.Now().Unix()
    if err != nil || ts <= 0 || ts < now-300 || ts > now+300 {
        return false
    }

    body, err := io.ReadAll(r.Body)
    if err != nil {
        return false
    }
    r.Body = io.NopCloser(bytes.NewReader(body)) // Preserve bytes for JSON decoding.

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp + "."))
    mac.Write(body)

    provided, err := hex.DecodeString(strings.TrimPrefix(signature, "sha256="))
    return err == nil && len(provided) == sha256.Size && hmac.Equal(provided, mac.Sum(nil))
}

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({
  verify: (req, res, buf) => { req.rawBody = Buffer.from(buf); }
}));

const HELPR_SECRET = process.env.HELPR_DATA_CONNECTOR_SECRET;

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

  if (!/^[1-9][0-9]*$/.test(timestamp || '') ||
      !/^sha256=[a-f0-9]{64}$/.test(signature || '') ||
      !Buffer.isBuffer(req.rawBody)) {
    return res.status(401).json({ error: 'Missing or malformed signature' });
  }
  const signedAt = Number(timestamp);
  if (!Number.isSafeInteger(signedAt) ||
      Math.abs(Math.floor(Date.now() / 1000) - signedAt) > 300) {
    return res.status(401).json({ error: 'Request too old' });
  }

  const expected = 'sha256=' + crypto
    .createHmac('sha256', HELPR_SECRET)
    .update(timestamp + '.', 'ascii')
    .update(req.rawBody)
    .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, os, time
from flask import Flask, request, jsonify

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

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

        if (not timestamp or len(timestamp) > 12
                or not timestamp.isascii() or not timestamp.isdecimal()
                or timestamp[0] == '0' or len(signature) != 71
                or not signature.startswith('sha256=')
                or any(c not in '0123456789abcdef' for c in signature[7:])):
            return jsonify(error='Missing or malformed signature'), 401
        if abs(int(time.time()) - int(timestamp)) > 300:
            return jsonify(error='Request too old'), 401

        body = request.get_data(cache=True)
        signed_payload = timestamp.encode('ascii') + b'.' + body
        expected = 'sha256=' + hmac.new(
            HELPR_SECRET.encode(), signed_payload, 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_DATA_CONNECTOR_SECRET'];

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

if (!preg_match('/^[1-9][0-9]*$/D', $timestamp)
    || !preg_match('/^sha256=[a-f0-9]{64}$/D', $signature)) {
    http_response_code(401);
    echo json_encode(['error' => 'Missing or malformed signature']);
    exit;
}
if (abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    echo json_encode(['error' => 'Request too old']);
    exit;
}

$rawBody = file_get_contents('php://input');
if ($rawBody === false) {
    http_response_code(400);
    echo json_encode(['error' => 'Could not read request body']);
    exit;
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $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_DATA_CONNECTOR_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']

  unless timestamp&.match?(/\A[1-9][0-9]*\z/) &&
         signature&.match?(/\Asha256=[a-f0-9]{64}\z/)
    halt 401, { error: 'Missing or malformed signature' }.to_json
  end
  if (Time.now.to_i - timestamp.to_i).abs > 300
    halt 401, { error: 'Request too old' }.to_json
  end

  signed_payload = timestamp.b + '.'.b + @raw_body.b
  expected = 'sha256=' + OpenSSL::HMAC.hexdigest('SHA256', HELPR_SECRET, signed_payload)

  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.