HUCK.CHATGOOD COMPANY. BETTER ANSWERS.Built to include everyone

Laravel, PHP, Node.js & Python

Server clients, secret management, durable updates and a secure session endpoint.

Examples should earn your trust. If a step is wrong, incomplete or out of date, use its report link to send the exact section to our team.

Connect the application that owns your data to Huck. Your server sends source changes and trusted identities; your browser receives only a short-lived chat session. These examples use the same REST API from Laravel 13, native PHP, Node.js/TypeScript and Python.

Start with API keys and errors, knowledge ingestion, access and identity, prompts and evaluations, or programmable actions for the domain contract. Code below runs in your application, not inside Huck.

Before you write a client

Report an issue with this section

Set HUCK_API_URL=https://huck.chat and HUCK_SERVICE_KEY in your server's environment or secret manager. Do not use a frontend-exposed environment prefix, commit a key, put it in a URL, or include it in logs. Use a dedicated key with knowledge:write for a source publisher, knowledge:read for readiness checks, and subjects:read, subjects:write, sessions:create for the backend chat-session route. Split those responsibilities into separate keys when different services own them.

Every mutation has a JSON object body. Reads have no body. Send Accept: application/json and Authorization: Bearer …; JSON bodies also need Content-Type: application/json. Keep TLS verification enabled and do not follow redirects when sending credentials. Treat the token as opaque: Sanctum's token format is not an identity document.

For a source write, persist an event ID and a complete payload snapshot together with your application change. Assign a monotonic revision to each external_type + external_id. Replaying that same event means using the same payload, revision and idempotency key. A new business change gets a new revision and event ID. Never generate a new key inside a retry loop. The server key itself is provisioned ahead of time; retries do not create or rotate credentials.

All examples below send this record, assuming your workspace has registered user_id with parent org_id:

{
  "external_type": "order",
  "external_id": "1042",
  "revision": 8,
  "title": "Alice's Thursday delivery",
  "content": {"status": "ready", "delivery_day": "Thursday"},
  "gates": {"org_id": "2", "user_id": "4"}
}

Use the actual revision from your database. Child gates and subject attributes require all ancestors; each ancestor has one value when a descendant is present. Labels and source relationships never fill in missing membership. Empty company-wide gates are {}, not []. In PHP use new \stdClass for an empty JSON object. Preserve nested object/list distinctions when storing an event; do not decode and re-encode an immutable snapshot in a way that changes its types. Use strings for external IDs, especially identifiers larger than JavaScript's safe integer range.

Laravel 13: configuration and HTTP client

Report an issue with this section

Read environment values in config/services.php, then use config() in application code so deployments using cached configuration work. Laravel's HTTP client supplies bearer headers, explicit connection/response timeouts and response inspection. Configuration reference, HTTP client reference.

// Add this entry to config/services.php.
'huck' => [
    'url' => env('HUCK_API_URL', 'https://huck.chat'),
    'key' => env('HUCK_SERVICE_KEY'),
],
<?php
// app/Services/HuckClient.php
namespace App\Services;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;

final class HuckClient
{
    public function request(): PendingRequest
    {
        $url = rtrim((string) config('services.huck.url'), '/');
        $key = (string) config('services.huck.key');
        if (parse_url($url, PHP_URL_SCHEME) !== 'https' || parse_url($url, PHP_URL_USER)
            || parse_url($url, PHP_URL_PASS) || $key === '') {
            throw new RuntimeException('Configure a secure Huck URL and server key.');
        }

        return Http::baseUrl($url.'/api/v1/')->withToken($key)
            ->acceptJson()->asJson()->connectTimeout(3)->timeout(12)
            ->withoutRedirecting();
    }
}

This client deliberately performs one attempt. A timeout can mean that Huck already accepted the write; the queue retries its saved event. Do not put blanket retries around permission updates, key creation, prompt publication or action invocation.

Laravel 13: publish changes after commit

Report an issue with this section

Queue work after the database transaction commits. Persist a durable outbox event as part of the transaction too: afterCommit() prevents dispatch before commit, but it cannot close the crash window between committing and publishing a queue message. A scheduled reconciler should requeue undelivered events. Both the job and reconciler must reuse the original event ID and payload. Laravel queue commit behavior.

In your application, create an outbox table with a UUID primary key id, JSON payload, nullable timestamp delivered_at, and timestamps. Treat an event's payload as immutable. This transaction fragment assumes $order is your locked order row, its revision was incremented for the change, and you imported DB, Str and your job:

DB::transaction(function () use ($order) {
    // Save your business change and increment revision in this transaction.
    $order->save();
    $eventId = (string) Str::uuid();
    $payload = [
        'external_type' => 'order', 'external_id' => (string) $order->id,
        'revision' => $order->revision, 'title' => $order->title,
        'content' => ['status' => $order->status],
        'gates' => ['org_id' => (string) $order->organization_id,
                    'user_id' => (string) $order->user_id],
    ];
    DB::table('huck_outbox')->insert([
        'id' => $eventId, 'payload' => json_encode($payload, JSON_THROW_ON_ERROR),
        'created_at' => now(), 'updated_at' => now(),
    ]);
    PublishHuckRecord::dispatch($eventId)->afterCommit();
});
<?php
// app/Jobs/PublishHuckRecord.php
namespace App\Jobs;

use App\Services\HuckClient;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\DB;
use RuntimeException;

final class PublishHuckRecord implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;
    public int $timeout = 25;
    public array $backoff = [5, 30, 120, 300];

    public function __construct(public string $eventId) {}

    public function handle(HuckClient $huck): void
    {
        $event = DB::table('huck_outbox')->find($this->eventId);
        if (! $event || $event->delivered_at) return;
        $reply = $huck->request()->withHeaders([
            'Idempotency-Key' => $this->eventId,
        ])->withBody($event->payload, 'application/json')->post('knowledge');

        if ($reply->status() === 202) {
            DB::table('huck_outbox')->where('id', $this->eventId)
                ->update(['delivered_at' => now(), 'updated_at' => now()]);
            return; // Accepted; indexing readiness is checked separately.
        }
        if ($reply->status() === 429) {
            $retryAfter = (string) $reply->header('Retry-After');
            $seconds = ctype_digit($retryAfter) ? (int) $retryAfter
                : max(0, (strtotime($retryAfter) ?: time()) - time());
            $this->release(max(5, $seconds));
            return;
        }
        $error = new RuntimeException('Huck HTTP '.$reply->status()
            .' request '.$reply->header('X-Request-ID'));
        if (in_array($reply->status(), [500, 502, 503, 504], true)) throw $error;
        $this->fail($error); // Reconcile 409/412/422; do not overwrite newer data.
    }
}

Configure a real queue connection, a worker, failed-job monitoring and the outbox reconciler. Keep the worker timeout below the queue's retry/visibility window. Connection failures throw and follow the same bounded job attempts. Duplicate deliveries are expected: Huck's record receipt deduplicates the immutable event. If an old revision receives 409, reconcile against your current authoritative record; do not increase that old event's revision merely to make it pass.

Laravel 13: trusted identity and browser session

Report an issue with this section

Place the following route in your application's routes/web.php. Keep its normal request-forgery protection and authentication middleware. Resolve all claims from the authenticated user's current database state; do not copy user_id, org_id, role or origin from browser input. Adapt the example's organization_id mapping to your own schema. The server key needs subjects:read, subjects:write and sessions:create.

use App\Services\HuckClient;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

Route::post('/huck/session', function (Request $request, HuckClient $huck) {
    $user = $request->user()->fresh();
    abort_unless($user && $user->organization_id !== null, 403);
    $subjectId = (string) $user->id;
    // Keep this interactive route's three network calls within a short budget.
    $client = fn () => $huck->request()->connectTimeout(2)->timeout(5);
    $current = $client()->get('subjects/'.rawurlencode($subjectId));
    if (! $current->ok() && $current->status() !== 404) {
        $current->throw();
        abort(502, 'Unexpected Huck response.');
    }
    $condition = $current->status() === 404
        ? ['If-None-Match' => '*'] : ['If-Match' => $current->header('ETag')];
    abort_if($current->ok() && ! $current->header('ETag'), 502);
    $updated = $client()->withHeaders($condition)->post('subjects', [
        'external_id' => $subjectId, 'active' => true,
        'attributes' => ['org_id' => (string) $user->organization_id,
                         'user_id' => $subjectId, 'role' => 'member'],
    ]);
    abort_if($updated->status() === 412, 409, 'Access changed. Reload before retrying.');
    $updated->throw();
    abort_unless(in_array($updated->status(), [200, 201], true), 502,
        'Huck did not acknowledge the identity update.');
    $sessionReply = $client()->post('sessions', [
        'subject' => $subjectId, 'origin' => rtrim(config('app.url'), '/'),
    ])->throw();
    abort_unless($sessionReply->status() === 200, 502,
        'Huck did not acknowledge the chat session.');
    return response()->json($sessionReply->json())->header('Cache-Control', 'no-store');
})->middleware(['auth', 'throttle:30,1']);

Use your application origin without a path and add it to Huck's origin allowlist. A stale ETag response stops this flow; reload authoritative account permissions before retrying. The explicit status checks also reject redirects: Laravel's throw() alone rejects only client/server errors, so it must not be used as proof that the identity was updated. If your app can suspend accounts, enforce its active-account middleware too. Your backend's permission synchronization must handle deactivation, organization moves and role changes, not just initial sign-in.

This Blade snippet sends your application's CSRF token to its own route. The browser receives the complete native session reply, including the opaque context_id used for safe refresh retries. Laravel request-forgery header documentation.

<meta name="csrf-token" content="{{ csrf_token() }}">
<script src="https://huck.chat/assets/huck-widget.js"></script>
<script type="module">
const chat = await Huck.mount({
  apiBase: 'https://huck.chat',
  sessionProvider: async () => {
    const response = await fetch('/huck/session', {
      method: 'POST', credentials: 'same-origin', cache: 'no-store',
      headers: { Accept: 'application/json',
        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content }
    });
    if (!response.ok) throw new Error('Please sign in again or reload your access.');
    return response.json();
  }
});
// Explicit sign-out: await chat.logout(); end your site session in finally.
// Navigation or account switch: chat.destroy(); discard old conversation IDs.
</script>

Native PHP with cURL

Report an issue with this section

This single-attempt server function uses the cURL extension, JSON encoding with exceptions, explicit timeouts and no redirects. The caller supplies an already persisted event ID and snapshot. Passing a JSON string to CURLOPT_POSTFIELDS avoids PHP's array-to-multipart behavior. PHP cURL options, timeout constants.

<?php
function publishHuckRecord(array $record, string $eventId): array
{
    $base = rtrim(getenv('HUCK_API_URL') ?: 'https://huck.chat', '/');
    $key = getenv('HUCK_SERVICE_KEY') ?: '';
    if (parse_url($base, PHP_URL_SCHEME) !== 'https' || parse_url($base, PHP_URL_USER)
        || parse_url($base, PHP_URL_PASS) || $key === '') {
        throw new RuntimeException('Configure the Huck server connection.');
    }
    $headers = [];
    $curl = curl_init($base.'/api/v1/knowledge');
    curl_setopt_array($curl, [
        CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 3, CURLOPT_TIMEOUT => 12,
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_HTTPHEADER => ['Authorization: Bearer '.$key,
            'Accept: application/json', 'Content-Type: application/json',
            'Idempotency-Key: '.$eventId],
        CURLOPT_POSTFIELDS => json_encode($record, JSON_THROW_ON_ERROR),
        CURLOPT_HEADERFUNCTION => function ($handle, string $line) use (&$headers): int {
            $pair = explode(':', $line, 2);
            if (count($pair) === 2) $headers[strtolower(trim($pair[0]))] = trim($pair[1]);
            return strlen($line);
        },
    ]);
    try {
        $body = curl_exec($curl);
        if ($body === false) {
            throw new RuntimeException('Huck transport failed; acceptance is unknown.');
        }
        $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
        return ['status' => $status, 'headers' => $headers,
            'data' => json_decode($body, true, 64, JSON_THROW_ON_ERROR)];
    } finally {
        curl_close($curl);
    }
}

Check status === 202 before marking the event accepted. Keep X-Request-ID for support and pass Retry-After to your queue scheduler on 429. A transport exception does not prove that nothing was saved. Retry the original event under the bounded policy below. The event ID is trusted internal data, at most 120 printable characters; do not build headers from arbitrary user text.

Node.js and TypeScript server fetch

Report an issue with this section

Use a supported Node runtime with native fetch and AbortSignal.timeout. This TypeScript helper does one bounded request and rejects redirects. Keep it in a server-only module; neither the key nor this module belongs in the browser bundle. Node global fetch and abort APIs.

type HuckRecord = {
  external_type: string;
  external_id: string;
  revision: number;
  title: string;
  content: unknown;
  gates: Record<string, string | number | Array<string | number>>;
};

export async function publishHuckRecord(record: HuckRecord, eventId: string) {
  const base = new URL(process.env.HUCK_API_URL ?? 'https://huck.chat');
  const key = process.env.HUCK_SERVICE_KEY;
  if (base.protocol !== 'https:' || base.username || base.password || !key) {
    throw new Error('Configure the Huck server connection.');
  }
  const response = await fetch(new URL('/api/v1/knowledge', base), {
    method: 'POST', redirect: 'error', signal: AbortSignal.timeout(12_000),
    headers: { Authorization: `Bearer ${key}`, Accept: 'application/json',
      'Content-Type': 'application/json', 'Idempotency-Key': eventId },
    body: JSON.stringify(record)
  });
  const data: unknown = await response.json();
  return { status: response.status, data,
    requestId: response.headers.get('x-request-id'),
    retryAfter: response.headers.get('retry-after') };
}

Native fetch does not reject merely because an HTTP response is 4xx or 5xx: inspect status. Network/abort errors can leave acceptance unknown. Your durable worker must retry the same stored record and eventId, and bound its total attempts and time. Do not create a fresh AbortSignal only once outside a retry loop; each actual attempt needs its own deadline. For plain JavaScript, remove the type declarations and annotations.

Python with Requests

Report an issue with this section

Reuse a requests.Session within a worker, configure explicit connection/read timeouts and keep redirects disabled. Requests' timeout tuple is not a total elapsed-time deadline: use your worker's own wall-clock limit as well. This example does not install automatic retries on every HTTP method. Requests sessions, timeout behavior.

import os
from urllib.parse import urlsplit
import requests

class HuckClient:
    def __init__(self):
        self.base = os.environ.get("HUCK_API_URL", "https://huck.chat").rstrip("/")
        parts = urlsplit(self.base)
        key = os.environ.get("HUCK_SERVICE_KEY")
        if parts.scheme != "https" or parts.username or parts.password or not key:
            raise RuntimeError("Configure the Huck server connection.")
        self.http = requests.Session()
        self.http.trust_env = False  # Do not let .netrc replace this bearer key.
        self.http.headers.update({"Authorization": f"Bearer {key}",
                                  "Accept": "application/json"})

    def publish(self, record, event_id):
        # record and event_id come from the same immutable outbox event.
        response = self.http.post(
            f"{self.base}/api/v1/knowledge", json=record,
            headers={"Idempotency-Key": event_id},
            timeout=(3.05, 12), allow_redirects=False,
        )
        return {"status": response.status_code, "data": response.json(),
                "request_id": response.headers.get("X-Request-ID"),
                "retry_after": response.headers.get("Retry-After")}

    def close(self):
        self.http.close()

Catch requests.exceptions.RequestException at your worker boundary. Inspect status codes separately; .json() succeeding does not mean the write succeeded. Always close the session on worker shutdown. Keep certificate verification enabled. This example disables ambient .netrc and proxy settings; configure an approved proxy or custom CA explicitly if your environment needs one. Requests authentication precedence. A timeout or connection failure follows the immutable-event retry policy; malformed data and permission failures require repair.

Handle responses and retries consistently

Report an issue with this section
Result Integration behavior
202 from source ingestion Record durable acceptance. Check readiness separately.
400 / 415 / 422 Fix JSON, content type, types, gates or full ancestor context. Do not retry unchanged invalid data.
401 with a service key Check expiry/revocation and replace configuration deliberately. Do not create a new key per retry.
401 with a chat session Refresh through your authenticated backend once; mutation replay requires the same context_id.
403 Check scopes, tenant, subject access and origin. No automatic credential renewal.
409 / 412 Reconcile revisions, policy versions or ETags from current authoritative state. Never blindly overwrite.
428 Supply the required If-Match or If-None-Match precondition.
429 Delay using Retry-After (seconds or an HTTP date), then retry the same eligible event within its attempt budget.
Network timeout / 500 / 502 / 503 / 504 Acceptance may be unknown. For an idempotent source event, retry the original key and payload with bounded backoff and jitter.

For a background source publisher, a reasonable starting policy is five attempts with delays around 5, 30, 120 and 300 seconds, adding jitter and respecting a longer Retry-After. Set an overall event deadline, alert after exhaustion, and retain the event for reconciliation. These are integration defaults, not a promise about completion time. In an interactive request, return a recoverable error instead of holding the browser open through background retry delays.

Do not apply source-write retry behavior universally. A duplicate action must use that invocation's existing idempotency key and preserve its confirmed inputs. A subject permission update uses the existing ETag contract. Prompt releases require the exact evaluated candidate and current fingerprints. Consult each domain guide before automating those mutations.

Confirm indexing readiness before testing answers

Report an issue with this section

After the 202 reply, retain its document.id, external_type, external_id and revision. Use a backend knowledge:read credential to read GET /api/v1/knowledge?external_type=order&page=1; follow pagination and locate that exact typed source. A ready record must have deleted: false, index_status: "ready", and indexed_revision === revision, with the revision you intended to publish. A newer revision supersedes your event: reconcile rather than treating a different version as proof that your exact event was indexed.

For larger collections, use GET /api/v1/knowledge/manifest?after_id=0&external_type=order, then advance after_id using next_after_id until null. The metadata-only manifest reports revision and indexing status without returning source content. It is a live listing, so repeat after writers settle if you need a stable reconciliation pass. Inventory provides indexed_revision when verifying the exact indexing generation. Never infer deletion from a missing item in one page or batch.

Poll from a worker or operator screen with a bounded schedule, such as after 1, 2, 4 and then 8 seconds, ending after a configured deadline. Stop on ready, deleted, superseded revision or failed indexing; show the state instead of a spinner that never ends. A polling timeout does not mean the source was rejected. Repair the cause and use POST /api/v1/knowledge/{externalId}/retry with its external_type when retrying a failed index. Do not create a fresh business revision just to repeat indexing.

Once the correct sources are ready, synchronize the subject, mint its session and run chat or evaluations using that subject's real context. Publishing a source, publishing a prompt and passing an evaluation are distinct operations.

Verify your integration before release

Report an issue with this section

Exercise one create, update and explicit delete; a lost response replay; a stale revision and ETag; an expired key; a 429 with delayed retry; and one denied cross-organization record. Confirm that a customer browser never receives a service key and an identity switch does not replay the previous user's message. Use test accounts and a separate workspace for destructive cases.

Framework references were checked against the official documentation on October 13, 2026. Examples use the API contract described in these guides; your application's schema, queue durability, authentication and account lifecycle remain yours to implement and test.

A little help.
A lot of possibility.

Your knowledge. Your assistant. Your identity.

Start a free Sandbox