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
| Purpose | Method | Resource | Use | Lifetime |
|---|---|---|---|---|
conversation-context | POST | Conversation | Once | 10 minutes |
contact-attributes | PATCH | Contact | Once | 10 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.mjsWhen a URL is rejected
- Tampered: any change to the method, path,
exp,nonceorsig. - Expired: past
exp. - Replayed: a single-use URL that was already used.
Mint a new URL and try again. Rejected requests change nothing.