API Reference

Callbacks

When something happens in a conversation — it opens, it completes, a record is deleted — confiqure POSTs a lifecycle event to your callback endpoint. The callback is a doorbell, not a package: it tells you something finished, here's its key — the data itself you fetch back over the data API. This page is the wire contract for those events.

Traffic runs the other way too: if you declare a type = FACTS class, confiqure GETs your app for what it already knows about a user, so the conversation never asks them for it. That contract is at the end of this page — The user-facts contract.

When callbacks fire

One POST per lifecycle event:

eventFires when
onStartA fresh conversation opened for an end user. Telemetry — most hosts ignore it.
config.savedA draft moved — one or more fields were saved to an instance this turn. Notification-only (no values); coalesced to one event per touched instance per turn. See Save events.
onCompleteConversational, not a data-readiness signal: fires when the user says they're done (a confirmation, a done-control click) or the origin task's session closes with the work finalized — never because confiqure judged the saved data "ready". Carries the finalized instances' confiqureKeys. See Draft vs finalized.
onSessionInterruptedA conversation closed without completing (abandoned on reopen, terminal error, tool timeout).
onSessionTimeoutA conversation was swept after exceeding the idle TTL.
config.deletedThe user deleted one saved instance through the chat. Carries the disposed confiqureKey — the confiqure-side record is already gone. Also fired per key (alongside the aggregate) for a small config.bulk_deleted scope-delete — see below.
config.bulk_deletedThe user deleted a whole scope of instances in one confirmed action. Carries a paged key manifest — see below.

Mid-chat calls to your @Confiqure.Tool endpoints are a separate dispatch (X-Confiqure-Event: tool.dispatch) to the tool's own route — not the callback endpoint.

Save events (config.saved)

— right-of-way redesign (2026-07-27). Completion is no longer a data-readiness gate (see onComplete above): the host learns about draft saves through THIS event, not by waiting for onComplete. GET/list reads already return every instance regardless of callbackStatus — config.saved is the push-notification half of that: pull the instance's data whenever you're notified it moved, on whatever cadence suits you.

{
  "event": "config.saved",
  "workspaceKey": "xrhrzc",
  "configEnd": "/warehouses",
  "endUserHandle": "user-3",
  "conversationId": 123,
  "confiqureKey": "d20d07d7af15",
  "changedFields": ["plan.dimensions", "name"],
  "callbackStatus": "PENDING",
  "timestamp": "2026-07-27T09:41:02.118"
}
  • Notification-only — never the values. changedFields names what changed; fetch the current record over the data API (GET .../data/{endUserHandle}/{confiqureKey}) if you want it.
  • Coalesced per turn. A chatty turn that saves several fields to the same instance fires one config.saved for that instance, not one per field. A turn that touches two different instances fires one event per instance.
  • Fired for chat-driven saves (nf_save, a cross-endpoint endpoint_accessor update, the consent/secret capture widgets, a confiqure.open() submit). Not fired for a host's own REST write (POST .../data/{endUserHandle}) — you already know what you just wrote.
  • callbackStatus mirrors the instance's current lifecycle state at save time (almost always PENDING; a save after completion — an edit through the chat — can carry DELIVERED).
  • Best-effort, same delivery semantics as every other event below (no retry, respond fast).

Which URL gets called

The callback URL is assembled from two workspace-level halves:

  1. 1hostBaseUrl — your app's absolute base URL. Deployment config: set it per environment in the Dashboard's workspace settings, never discovered from code. A sandbox workspace without its own host URL inherits its production parent's.
  2. 2The callback hook path — the relative route of your @Confiqure.DefaultCallbackHook handler, discovered from your annotated source on every confiqure push. Read-only in the Dashboard: it's code, identical across environments.

If either half is missing, events are skipped (and logged confiqure-side) — values are still stored and readable over the data API. During local development, a running confiqure listen pipe receives the events instead of the host POST, same body.

The request

POST <hostBaseUrl + hookPath>
Content-Type: application/json
X-Confiqure-Event: onComplete                      // same as body.event
X-Confiqure-Delivery-Id: lc-<conversationId>-<event>
X-Confiqure-Signature: <signature>                 // only when callback signing is enabled
{
  "event": "onComplete",
  "workspaceKey": "xrhrzc",
  "configEnd": "/suppliers",
  "endUserHandle": "user-3",
  "conversationId": 9,
  "confiqureKeys": ["d20d07d7af15"],
  "timestamp": "2026-07-02T23:42:18.401",
  "path": "/api/confiqure/callback"
}

Field notes, in the order integrations get them wrong:

  • event is the key — not eventType. Values are exactly the camelCase strings in the table above.
  • The payload does NOT carry configuration values. It carries confiqureKeys. On onComplete, fetch each key back over the data API and provision from the returned record. There is no inline data/values object.
  • configEnd always carries the leading slash (/suppliers). Normalize once at the top of your handler.
  • confiqureKeys may list several keys in one onComplete (a completion can deliver more than one touched instance). It is empty for events that aren't about instances (onStart, session closes).
  • endUserHandle is the ACTING user, always — even on an org-scoped endpoint, the payload names the member who acted, never an org handle. Derive the org from your own membership table, or fetch the record by key.
  • path echoes your discovered callback hook path; you can ignore it.

Deletion events

config.deleted replaces confiqureKeys with a singular confiqureKey — the disposed instance. By the time you receive it, the confiqure-side record is gone (a GET by that key returns 404); the event exists so you can clean up your own copy.

config.bulk_deleted carries a paged manifest instead:

{
  "event": "config.bulk_deleted",
  "workspaceKey": "xrhrzc",
  "configEnd": "/suppliers",
  "endUserHandle": "user-3",
  "conversationId": 9,
  "count": 1200,
  "page": 1,
  "totalPages": 3,
  "keys": ["…500 keys…"],
  "timestamp": "2026-07-12T10:02:44.120"
}

Pages of 500 keys, each page its own delivery. A host that mirrors per-key state consumes every page; one that only cares "something changed" reads page 1's count.

Delivery semantics

  • Single best-effort POST, no retry — except onComplete. Connect timeout 5 s, whole-request timeout 10 s, unchanged. Every event but onComplete is exactly what it always was: one attempt, and a non-2xx response or network error is logged confiqure-side and not redelivered. onComplete is the one event with no later self-healing signal (config.saved losses self-heal on the next save; a dropped onComplete previously had no recovery), so on a timeout, 5xx, or connect failure it is retried up to 3 total attempts (1 initial + 2 retries), backing off ~5s then ~20s between them, off the request thread — a slow host handler never blocks your integration. Still best-effort: reconcile anything critical via the data API on your own paths (an onboarded-gate should verify against a GET, not only the callback).
  • Respond fast with 2xx, and ack before you do the work. The 10-second timeout counts your handler's synchronous work — do heavy provisioning async. This matters even more for onComplete now: a handler that's merely slow (not actually broken) can trigger a retry it didn't need, so the fix is the same one that's always been correct — acknowledge fast, then work in the background.
  • Dedupe onComplete retries on X-Confiqure-Delivery-Id — same value on every attempt. X-Confiqure-Delivery-Id is stable per (conversation, event); a retry is the exact same payload re-sent, not a new event. Each onComplete attempt additionally carries X-Confiqure-Delivery-Attempt: <1|2|3> if you want to log or reason about which retry you're seeing — dedupe on the delivery id, not the attempt number (a slow-but-successful attempt 1 can still be followed by a retry that arrives after your handler already finished; the id is what tells you it's the same delivery). Keep onComplete handling idempotent regardless: a later edit through the chat ends in another onComplete (a new delivery id) for the same key.
  • Exhaustion is logged confiqure-side, not surfaced to you. If all onComplete attempts fail, there is no further signal beyond confiqure's own logs — the completion event is genuinely lost at that point, same as any other exhausted best-effort delivery. This is why onComplete retries exist at all, but it doesn't turn the callback into guaranteed delivery; keep treating it as a prompt to fetch, not a source of truth.
  • Signing is opt-in per workspace — see Verifying callback signatures below. No mode configured → the signature header is absent. Each onComplete retry re-signs the (unchanged) body with a fresh timestamp, so a host enforcing a signature replay window doesn't reject a later retry as stale.

Draft vs finalized — callbackStatus on your data

Every record the chat saves is a draft (callbackStatus: PENDING) until the user says they're done; that conversational confirmation flips it to DELIVERED and fires onComplete with its key. Two things follow:

callbackStatusMeaning
PENDINGA draft — saved values, no onComplete yet. Host-seeded records (a REST write) are also PENDING; a REST write never fires onComplete — you already know what you wrote.
DELIVEREDThe user said they're done and its onComplete fired.
  • DELIVERED is a conversational signal, not a data-quality guarantee. Confiqure applies no readiness judgment at completion — it does not evaluate whether your DTO's required fields are filled, or hold delivery on one that doesn't quite fit your shape. A record that doesn't deserialize cleanly into your @Confiqure class still delivers; its issues land in the envelope's errors/warnings maps ({data,errors,warnings}) instead of blocking the webhook. Your own convertValue/deserializer is still the authority on whether a record is usable — treat a conversion failure as your own signal, the same way you always would.
  • The list read returns every instance, drafts included — and, since 2026-07-28, including a record whose stored data won't bind into your DTO (it ships in the same {data, errors, warnings} envelope, with its errors map filled in, rather than being dropped from the array). Filter on callbackStatus, and don't use completedAt to tell drafts apart (it doubles as the last-write time until completion, so it is non-null on drafts too). The only record the list withholds is one held mid-migration.
  • callbackStatus describes the instance's lifecycle state, not whether the webhook POST succeeded — delivery success is only visible in confiqure-side logs.
  • Prefer config.saved (above) over waiting for onComplete if you want to react to data as it's collected rather than only once the user declares themselves finished.

The user-facts contract — the one call that goes the OTHER way

Every callback above is confiqure telling your app something happened. The user-facts contract is the inverse: confiqure asks your app what it already knows about a user, so the conversation stops asking them for it. Nothing else in this document is a GET.

You declare it as an ordinary class — the fields are yours, and the field comments are the contract, because they are what tells the model what each fact means:

@Confiqure(type = Confiqure.Type.FACTS, callback = "/api/confiqure/user-facts")
public class SellerFacts {
    /** The product categories this seller actually sells in. */
    private List<String> sellingCategories;
    /** How many of their listings are currently stranded. */
    private Integer strandedCount;
    /** Their open support issues, newest first. */
    private List<String> openIssues;
}

Push it with confiqure push like any other class, then implement the one endpoint:

GET <hostBaseUrl + callback>?endUserHandle=user-3
Accept: application/json
X-Confiqure-Event: user.facts.fetch
X-Confiqure-Workspace: xrhrzc
X-Confiqure-End-User: user-3
X-Confiqure-Delivery-Id: uf-<workspaceId>-<endUserHandle>
X-Confiqure-Signature: <signature>          // only when callback signing is enabled

One field name is reserved and read by the engine by name: preferredLanguages — a list of language names in English, most preferred first (for example ["Turkish", "English"]). It sets the language of the chat's opener and is the list the model checks a message against first when deciding the reply language; the engine appends English when it is missing and uses English alone when your facts do not carry the field. Send your user's real preference, not only their UI locale, or a seller who writes Turkish is treated as an English speaker until they write a longer sentence.

Return a flat JSON object whose fields match the class:

{
  "sellingCategories": ["Books", "Electronics"],
  "strandedCount": 12,
  "openIssues": ["Late shipment on order 8821"]
}

Field notes, in the order hosts will get them wrong:

  • type = FACTS is not a chat endpoint. The class is read-only and host-owned: the conversation never writes it, it has no saved instances, no data API routes, and it never appears in the endpoint list. It is a contract, not a configuration.
  • callback is required and relative (/api/confiqure/user-facts) — it is combined with the same workspace hostBaseUrl as the lifecycle hook, so it is identical across environments and travels sandbox → prod on promote. A FACTS class with no callback is rejected at push, loudly.
  • The user is in the query string and the header, never the body — a GET has no body. endUserHandle is always the ACTING user, exactly as in the webhooks above.
  • You are answering for ONE user. Return only their facts — no list, no envelope, no {"data": …} wrapper. A response that is not a JSON object is dropped.
  • Answer fast, or don't answer. Nothing waits on you: the fetch is fired in the background at chat open with a 10-second timeout, and the conversation starts regardless.
  • Cached for 24 hours per user. A refresh is fired at session open and used from the next open, so a fact your app changed a minute ago typically appears in the user's next conversation, not mid-sentence in this one. Facts are for stable context (categories, counts, plan), not for live values a tool should fetch.
  • A failure is invisible to the user. If your endpoint is down, times out, or returns a shape that no longer deserializes into the declared class, confiqure keeps serving the last good facts and logs the failure — stale, never broken. That also means a silently-wrong response can persist: it is rejected whole, never partially applied.
  • Signing works the same, with one difference. Because there is no body, the signed payload is the canonical request line — GET /api/confiqure/user-facts?endUserHandle=user-3 — in place of the raw body, with the same <t>.<payload> construction and the same workspace auth mode described in the signing reference below.
  • Never return secrets. The facts become model-visible prompt text and searchable content (see below). Anything you would not want a chat to quote back does not belong here.

What the model sees is a compact, explicitly host-attributed block — your field name, your comment, and the value — with standing instructions never to ask for anything it answers, never to replay a fact as something the user said, and to let the user win on any conflict.

That block is deliberately compact: long lists are shown truncated so the facts never crowd out the conversation. The full, untruncated payload is also indexed for search, per user, and the chat looks there before asking the user for anything — so a seller with two hundred selling categories still gets a correct answer to "do I sell garden furniture?", even though the block only showed the first dozen. Two practical consequences:

  • Long fields are worth returning. A complete list is more useful than a pre-truncated one; the search layer is what makes its tail reachable.
  • The index is per user and never shared. Facts are scoped to the same (workspace, endUserHandle) as everything else, and a search can only ever return the asking user's own facts.

Host-side checklist

  1. 1Read event, match the exact strings in the table above.
  2. 2Normalize configEnd's leading slash once.
  3. 3On config.saved: pull the instance if you care; it's advisory, ignore it if you don't.
  4. 4On onComplete: GET by confiqureKeys, then provision — never expect inline values; validate the shape yourself (see DELIVERED above — it is not a readiness guarantee). It may arrive up to 3 times (bounded retry, same X-Confiqure-Delivery-Id each time) — dedupe on that header, and ack fast so a merely-slow handler doesn't trigger a retry it didn't need.
  5. 5On config.deleted / config.bulk_deleted: clean up your copies; the confiqure-side records are already gone. A small bulk delete also fires per-key config.deleted events — handling either (or both) keeps you correct.
  6. 6Return 2xx quickly; provision async; reconcile critical state via GET, since delivery is best-effort.
  7. 7If you declared a type = FACTS class: serve its GET fast, per user, and comment every field — the comments are what the model reads.

Verifying Confiqure callback signatures

Confiqure signs every server-side tool dispatch and lifecycle callback it POSTs to your host so you can prove the request came from Confiqure. Pick a mode per workspace in the Dashboard → Host integration → Callback signing:

  • JWKS (recommended) — asymmetric. Confiqure signs with a private key; you verify against the public keys it publishes. Zero secrets to store or rotate. Jump to JWKS mode.
  • HMAC — a shared secret shown once at enable/rotate. Familiar webhook standard.

Both modes use the same X-Confiqure-Signature header and the same ±300s replay window; a workspace is in exactly one mode at a time, so verify according to the mode you enabled.

X-Confiqure-Signature carries a compact JWS (alg ES256) whose payload is "<unix_seconds>." + raw_request_body, with a kid in the JWS header.

X-Confiqure-Signature: <base64url(header)>.<base64url(payload)>.<base64url(signature)>
X-Confiqure-Delivery-Id: <stable id, dedupe on this>
X-Confiqure-Event: tool.dispatch | onStart | onComplete | onSessionInterrupted | onSessionTimeout

To verify:

  1. 1Fetch + cache Confiqure's public keys from https://<your-confiqure-api>/.well-known/confiqure/jwks.json (the Dashboard shows the exact URL). It's a standard JWKS document ({"keys":[...]}).
  2. 2Parse the JWS header, read kid, pick the matching key from the JWKS (refetch if the kid is unknown — that means a rotation).
  3. 3Verify the JWS signature (ES256) against that public key.
  4. 4Decode the payload and split on the first .: the left part is t (unix seconds), the rest is the body Confiqure signed. Reject if abs(now - t) > 300, and confirm the body matches the request body you received.

Sandbox and production sign with different keys (different kids) — both are served from the same JWKS document, so matching on kid keeps them separate automatically.

Node (Express) — JWKS

const { createRemoteJWKSet, compactVerify } = require("jose"); // npm i jose

const JWKS = createRemoteJWKSet(new URL(process.env.CONFIQURE_JWKS_URL));

async function verifyConfiqureJws(rawBody, header) {
  const { payload } = await compactVerify(header, JWKS);           // throws if signature/kid bad
  const text = new TextDecoder().decode(payload);
  const dot = text.indexOf(".");
  const t = parseInt(text.slice(0, dot), 10);
  const signedBody = text.slice(dot + 1);
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;        // replay window
  return signedBody === rawBody;                                   // body integrity
}

Python (Flask) — JWKS

from jwt import PyJWKClient                       # pip install pyjwt[crypto]
import jwt as pyjwt, time

_jwks = PyJWKClient(os.environ["CONFIQURE_JWKS_URL"])

def verify_confiqure_jws(raw_body: bytes, header: str) -> bool:
    key = _jwks.get_signing_key_from_jwt(header).key       # resolves kid against the JWKS
    # PyJWT can't decode the non-JSON payload, so verify the signature manually:
    from jwt.algorithms import ECAlgorithm
    signing_input, _, sig = header.rpartition(".")
    if not ECAlgorithm(ECAlgorithm.SHA256).verify(
            signing_input.encode(), key, pyjwt.utils.base64url_decode(sig)):
        return False
    h, _, p = header.split(".")
    payload = pyjwt.utils.base64url_decode(p).decode()
    t_str, _, signed_body = payload.partition(".")
    if abs(time.time() - int(t_str)) > 300:
        return False
    return signed_body.encode() == raw_body

HMAC mode (shared secret)

Enable it in the Dashboard and copy the signing secret shown once at enable/rotate.

The scheme (HMAC-SHA256)

Each callback carries:

X-Confiqure-Signature: t=<unix_seconds>,v1=<hex(HMAC_SHA256(secret, "<t>." + raw_request_body))>
X-Confiqure-Delivery-Id: <stable id, dedupe on this>
X-Confiqure-Event: tool.dispatch | onStart | onComplete | onSessionInterrupted | onSessionTimeout

To verify:

  1. 1Read X-Confiqure-Signature; split out t and v1.
  2. 2Recompute HMAC_SHA256(secret, t + "." + rawBody) as lowercase hex — over the raw request body bytes, before any JSON re-parse/re-serialize.
  3. 3Constant-time compare against v1.
  4. 4Reject if abs(now - t) > 300 seconds (replay window).

Secrets are per-workspace and per-environment (sandbox and production differ). Rotation keeps the previous secret valid during an overlap window — accept either while you roll.


Node (Express)

const crypto = require("crypto");

function verifyConfiqure(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = parseInt(parts.t, 10);
  if (Math.abs(Date.now() / 1000 - t) > 300) return false;            // replay window
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected), b = Buffer.from(parts.v1 || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// Express: capture the RAW body — express.json({ verify: (req, _res, buf) => { req.rawBody = buf } })
app.post("/api/confiqure/hooks/lifecycle", (req, res) => {
  if (!verifyConfiqure(req.rawBody, req.get("X-Confiqure-Signature"), process.env.CONFIQURE_SECRET))
    return res.status(401).end();
  // … handle req.body …
  res.sendStatus(200);
});

Python (Flask)

import hmac, hashlib, time

def verify_confiqure(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    t = int(parts["t"])
    if abs(time.time() - t) > 300:
        return False
    msg = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

# Flask: use request.get_data() for the RAW bytes (do not use request.json first).

Java (Spring)

static boolean verify(byte[] rawBody, String header, String secret) throws Exception {
    Map<String,String> p = new HashMap<>();
    for (String kv : header.split(",")) { String[] s = kv.split("=", 2); p.put(s[0], s[1]); }
    long t = Long.parseLong(p.get("t"));
    if (Math.abs(System.currentTimeMillis() / 1000 - t) > 300) return false;
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
    byte[] h = mac.doFinal(rawBody);
    StringBuilder hex = new StringBuilder();
    for (byte b : h) hex.append(String.format("%02x", b));
    return MessageDigest.isEqual(hex.toString().getBytes(), p.getOrDefault("v1", "").getBytes());
}
// Read the raw body with @RequestBody byte[] (or a ContentCachingRequestWrapper).

Go

func verify(rawBody []byte, header, secret string) bool {
    parts := map[string]string{}
    for _, kv := range strings.Split(header, ",") {
        if p := strings.SplitN(kv, "=", 2); len(p) == 2 { parts[p[0]] = p[1] }
    }
    t, _ := strconv.ParseInt(parts["t"], 10, 64)
    if math.Abs(float64(time.Now().Unix()-t)) > 300 { return false }
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(fmt.Sprintf("%d.", t)))
    mac.Write(rawBody)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}

PHP

function verify_confiqure(string $rawBody, string $header, string $secret): bool {
    parse_str(str_replace(',', '&', $header), $p); // t=...&v1=...
    if (abs(time() - (int)$p['t']) > 300) return false;
    $expected = hash_hmac('sha256', $p['t'] . '.' . $rawBody, $secret);
    return hash_equals($expected, $p['v1'] ?? '');
}
// Read the raw body with file_get_contents('php://input').

Notes

  • Always verify against the raw body bytes; frameworks that re-serialize JSON will change whitespace/key order and break the check.
  • X-Confiqure-Delivery-Id is stable across retries — dedupe on it for idempotency.
  • During the local-dev confiqure listen flow, deliveries are replayed to your localhost; signature behaviour matches production so you can test verification end to end.