Signed URLs

A signed URL lets you write one specific thing, once, for a few minutes, without an API key. Use it to attach an order or account details to a conversation, or hand it to a service that should not hold your key.

Format

{api}/v1/signed/{purpose}/{resource_id}?exp={unix}&nonce={uuid}&sig={hex}

The signature covers the HTTP method, the path, the expiry and the nonce. Change any of them and the URL stops working.

Purposes

PurposeMethodResourceUseLifetime
conversation-contextPOSTConversationOnce10 minutes
contact-attributesPATCHContactOnce10 minutes

Request bodies are JSON, at most 256 KB, and are checked against the purpose's schema.

1. Mint the URL

Call the API from your server with a key that has the signed_urls:write scope:

POST /v1/signed-urls
Authorization: Bearer kz_…
Content-Type: application/json

{ "purpose": "conversation-context", "resource_id": "<conversation id>" }

The response holds url, method, purpose, expires_at and multi_use. Pass expires_in (seconds, at least 30) for a shorter lifetime.

2. Use it

For conversation-context, send { "context": { … } }: flat keys made of letters, digits and underscores, with string, number, boolean or null values, at most 50 keys and 8 KB. It replaces the context agents see in the customer panel.

/**
 * Attach context (an order, a plan, a page) to a conversation through a single-use signed URL.
 * Step 1 asks the KauZap API for the URL with your API key (scope `signed_urls:write`);
 * step 2 posts the JSON to it. The URL works once and expires after 10 minutes, so you can
 * also hand it to another service that should not hold your API key.
 *
 * @param {object} opts
 * @param {string} opts.apiBase e.g. https://<project>.supabase.co/functions/v1/api
 * @param {string} opts.apiKey A kz_... API key. Keep it on your server.
 * @param {string} opts.conversationId Conversation UUID.
 * @param {Record<string, string | number | boolean | null>} opts.context Flat key/value pairs
 *   (at most 50 keys, 8 KB). Replaces the context agents see in the customer panel.
 * @param {typeof fetch} [opts.fetch]
 * @returns {Promise<unknown>} The API's response body.
 */
export async function postConversationContext({
  apiBase,
  apiKey,
  conversationId,
  context,
  fetch = globalThis.fetch,
}) {
  const minted = await fetch(`${apiBase}/v1/signed-urls`, {
    method: 'POST',
    headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
    body: JSON.stringify({ purpose: 'conversation-context', resource_id: conversationId }),
  });
  const { url } = await readJson(minted, 'Signed URL request');

  const posted = await fetch(url, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ context }),
  });
  return readJson(posted, 'Context post');
}

async function readJson(res, step) {
  const body = await res.json().catch(() => null);
  if (!res.ok) {
    const code = body?.error?.code ?? 'unknown_error';
    throw new Error(`${step} failed: ${res.status} ${code}`);
  }
  return body;
}
examples/node-integration/conversation-context.mjs

When a URL is rejected

Mint a new URL and try again. Rejected requests change nothing.