An action connects a conversation to work performed by your own service: look up an order, create a support ticket, or start a report. Huck validates the input and authorization, delivers a signed request, tracks progress, and resumes the conversation after a successful result.
Your service owns the actual side effect and its business authorization. Huck does not execute arbitrary customer scripts. Delivery can repeat, so your receiver must durably deduplicate work before it performs a side effect.
Register a definition
Report an issue with this sectionUse POST /action-definitions with an actions:write service key. Store a random signing secret of at least 32 characters in both Huck and your receiver's secret manager. Do not put it in a browser. Definitions are listed with GET /action-definitions using actions:read; secret fields are hidden from responses.
{
"name": "lookup_order",
"endpoint": "https://your-app.example/api/hooks/huck/orders",
"secret": "REPLACE_WITH_A_RANDOM_SECRET_OF_AT_LEAST_32_CHARACTERS",
"required_gates": {"org_id":"2"},
"confirmation_required": true,
"schema": {
"type":"object",
"properties":{"order_id":{"type":"string","maxLength":100}},
"required":["order_id"],
"additionalProperties":false
},
"output_schema": {
"type":"object",
"properties":{"status":{"type":"string","maxLength":100}},
"required":["status"],
"additionalProperties":false
}
}
Use a real publicly reachable HTTPS hostname on port 443. Localhost, private/reserved IP addresses, IP-literal endpoints, credentials in URLs, redirects, and non-HTTPS targets are unsupported. DNS addresses are checked at delivery and pinned for that request. A local integration needs a public HTTPS receiver or a trusted development tunnel endpoint.
name is a lowercase identifier up to 80 characters. Input and output schemas must have an object root. Huck supports a bounded JSON Schema subset: type, properties, required, additionalProperties: false, items, enum, maxLength, maxItems, minimum, maximum, and description. Types are object, array, string, integer, number, and boolean. $ref, oneOf, executable validation, and arbitrary schema keywords are not supported. Payloads are limited to 65,536 encoded bytes and schema traversal to depth 6.
Required gates use your current published policy and must include configured ancestors. Read-all or manager read bypasses do not implicitly authorize an action: required gates must match directly. Your receiver must also verify that the referenced order belongs to the acting person or organization in your own database.
The console can enable/disable definitions. The public API currently creates and lists definitions; it does not expose an update endpoint. To change an endpoint/schema/secret through the API, create a new definition, switch your integration to its returned ID, then disable the old definition in the console. In-flight invocations retain their original signing secret and schemas, so retain the receiver's matching secret until accepted work is reconciled.
Invoke as the end user
Report an issue with this sectionUse a valid chat-session bearer and its exact approved Origin, not the service key. Start or select an owned conversation; POST /chat returns its conversation_id and requires its own idempotency key.
curl --fail-with-body "$HUCK_API_URL/api/v1/conversations/$CONVERSATION_ID/actions/$DEFINITION_ID/invoke" \
-H "Authorization: Bearer $HUCK_CHAT_TOKEN" \
-H 'Origin: https://your-website.example' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: order-42-lookup-click-01' \
--data '{"input":{"order_id":"42"}}'
The invocation key is 8–128 characters and identifies one logical action request. Same key plus identical input replays the invocation; changed input returns 409. Only one nonterminal action may exist per conversation.
The 202 response contains data.id and data.status. When confirmation is required, show the user the intended action and input, then call POST /actions/invocations/{id}/confirm with {} after they confirm. Confirmation also returns 202; it is not proof the external work completed. Do not auto-confirm a destructive action because a model proposed it.
Poll GET /actions/invocations/{id} using the same subject session and Origin. It returns data and continuation_status. Useful states are pending_confirmation, queued, dispatched, running, succeeded, failed, cancelled, and timed_out. The invocation deadline is one hour. Access changes can block reads or delivery even when you still know the invocation ID.
Verify the exact webhook bytes
Report an issue with this sectionHuck sends an HTTPS POST with these headers:
| Header | Meaning |
|---|---|
X-Huck-Timestamp |
Unix timestamp in seconds for this delivery attempt. |
X-Huck-Event |
Stable event ID for this delivery. |
X-Huck-Signature |
sha256= followed by HMAC-SHA256 hex. |
X-Huck-Key-Version |
Definition version captured for this invocation. |
Idempotency-Key |
Invocation UUID; use this to prevent repeated side effects. |
The signed message is the exact concatenation timestamp + "." + event_id + "." + raw_body. Verify with a constant-time comparison before trusting any field. JSON reserialization changes bytes and invalidates a signature.
The body contains event_id, invocation_id, action, action_version, idempotency_key, subject (external_id and version), input, callback_url, and expires_at. The delivery's HTTP success means only that your receiver accepted it. Returning a result in that HTTP response does not update Huck; send a signed callback.
Laravel receiver verification, placed in your application's API route/controller:
use Illuminate\Http\Request;
use Illuminate\Support\Str;
function verifyHuckDelivery(Request $request, string $secret): array
{
$raw = $request->getContent();
$timestamp = (string) $request->header('X-Huck-Timestamp');
$eventId = (string) $request->header('X-Huck-Event');
$signature = (string) $request->header('X-Huck-Signature');
abort_unless(strlen($raw) <= 131072 && ctype_digit($timestamp)
&& abs(time() - (int) $timestamp) <= 300 && Str::isUuid($eventId), 401);
$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$eventId.'.'.$raw, $secret);
abort_unless(hash_equals($expected, $signature), 401);
$data = json_decode($raw, true, 16, JSON_THROW_ON_ERROR);
abort_unless(is_array($data) && ($data['event_id'] ?? null) === $eventId
&& is_string($data['invocation_id'] ?? null) && Str::isUuid($data['invocation_id'])
&& ($data['idempotency_key'] ?? null) === $data['invocation_id']
&& $request->header('Idempotency-Key') === $data['invocation_id'], 422);
$expectedCallback = rtrim(config('services.huck.url'), '/')
.'/api/v1/actions/invocations/'.$data['invocation_id'].'/callback';
abort_unless(($data['callback_url'] ?? '') === $expectedCallback, 422);
return $data;
}
Configure the receiver secret for this definition locally; do not let the incoming body choose a remote secret source. For multiple definition versions, select only from a fixed local secret registry and validate the signed action/version binding. Bound your request size before reading the body at the web server as well. Translate JSON parse failures into a rejected request.
Accept once and execute durably
Report an issue with this sectionCreate an application-owned receipt/outbox table with a unique invocation_id, the original body hash, the validated input, and a processing state. After signature and local authorization checks, insert the receipt in a database transaction. On a duplicate, compare the original body hash: acknowledge an identical delivery, reject a conflicting one. Return 202 only after the receipt is durably saved.
Have your worker claim the receipt and perform the action. Check the signed expires_at before accepting new work and again before the side effect; recheck the subject's current local business permissions as well. The claim, domain mutation and receipt completion should share a database transaction when they live in one database. For an external provider, pass the invocation UUID as that provider's idempotency key and reconcile uncertain outcomes. A cache flag or “job was dispatched” is not durable proof that the side effect ran once.
Huck retries failed transport delivery with bounded backoff, up to eight attempts, subject to the action deadline. The original event ID/body stay stable while the delivery timestamp/signature can change. Your receiver should respond within the 15-second delivery timeout; run long work in a queue. Non-2xx responses, timeouts, oversized responses, and redirects count as delivery failures. Exhaustion means the external outcome may be unknown, not necessarily that no work happened.
Report progress and completion
Report an issue with this sectionSend a signed POST to the fixed callback URL for this invocation. Callbacks use the same HMAC message format, but event_id comes from the callback's body. Huck reads X-Huck-Timestamp and X-Huck-Signature; a service key or chat token is not required for this HMAC-authenticated endpoint.
{
"event_id":"9f600ef7-c05b-4a7b-b432-2bb7b2e9cc41",
"invocation_id":"e50612f0-5a87-49bb-bd77-ce45fbd08a87",
"sequence":1,
"status":"running",
"progress":40
}
Use your actual invocation UUID. A successful final callback has a higher sequence, status: "succeeded", progress: 100, and output matching the registered result schema. For the definition above, the output is {"status":"paid"}. A failed action can send status: "failed"; the conversation becomes available again.
Laravel callback sender:
use Illuminate\Support\Facades\Http;
// $event is a durable outbox record: never regenerate its event_id or body on retry.
$raw = $event->raw_body;
$payload = json_decode($raw, true, 16, JSON_THROW_ON_ERROR);
$timestamp = (string) time();
$signature = 'sha256='.hash_hmac('sha256',
$timestamp.'.'.$payload['event_id'].'.'.$raw, $event->signing_secret);
$url = rtrim(config('services.huck.url'), '/')
.'/api/v1/actions/invocations/'.$event->invocation_id.'/callback';
$response = Http::withOptions(['allow_redirects' => false])->acceptJson()
->withHeaders(['X-Huck-Timestamp' => $timestamp, 'X-Huck-Signature' => $signature])
->connectTimeout(5)->timeout(20)->withBody($raw, 'application/json')->post($url);
$response->throw();
if ($response->status() !== 200) {
throw new RuntimeException('Huck did not acknowledge the callback: HTTP '.$response->status());
}
// Only now mark this exact callback event acknowledged in your outbox.
Keep secrets encrypted at rest or reference them through your secret manager. A callback event ID must be 8–128 characters; sequence and progress must be JSON integers. Sequence increases monotonically; accepted progress cannot decrease. A running callback cannot include final output. Callbacks are bounded to 131,072 bytes and a five-minute timestamp window.
After a timeout, resend the same event ID and exact body, generating a fresh timestamp/signature. The same event with changed bytes returns 409. An older sequence can be recorded without changing current progress. Once an invocation is terminal, new state-changing events return 409; an exact retry of an already-recorded event remains idempotent.
Resume, cancel, and reconcile
Report an issue with this sectionA successful callback queues a durable chat continuation. succeeded and “chat resumed” are separate milestones. Poll continuation_status and fetch GET /conversations/{conversationId} for the resulting message. Continuation rechecks current authorization and treats action output as untrusted data; it cannot grant access or override the outer prompt. Changed permissions or source context may prevent a continuation from becoming a visible answer.
POST /actions/invocations/{id}/cancel with {} stops Huck's pending delivery/continuation for a nonterminal invocation. It does not undo work already accepted by your receiver and does not send an automatic cancellation command to your external system. Reconcile accepted side effects using your invocation receipt. Completed actions are immutable.
Test duplicate delivery, a lost callback response, out-of-order progress, an invalid signature, expired timestamps, access revocation, timeout, and cancellation after your receiver has accepted work. These are normal integration failure modes, not reasons to generate new action IDs automatically.
Continue with API errors, access policy, or evaluation release checks.