Now liveBe one of the first 50 founders of Malleable. Founding members keep a lifetime discount.

Documentation

API Keys, Audit Log & Webhooks

Creating an API Key

Malleable issues two flavors of key, both prefixed mk_live_ followed by 32 bytes of base64url randomness. Personal integration keys live in the agent_api_keys table and are managed from the dashboard's Bring your agents section. Per-client embed keys live in customer_api_keys and are managed from the contact slide-over on a contact record.

Both key types are reveal-once. The full token is shown exactly one time (at the moment of creation) inside an amber callout that reads "Copy this key now, it will not be shown again". After you dismiss that panel, only the preview survives. The preview is the first 12 characters plus the last 4, joined by an ellipsis, e.g. mk_live_aB3x...9zQk. The server never stores the plaintext: the database row holds a SHA-256 hash in key_hash and the truncated key_preview for display.

Personal keys (for your own agent / CLI)

POST /api/integration-keys
{
  "name": "Claude Desktop",
  "scopes": ["calendar:read", "calendar:write", "tasks:read"]
}

200 OK
{
  "key": {
    "id": "...",
    "name": "Claude Desktop",
    "keyPrefix": "mk_live_aB3x...9zQk",
    "scopes": ["calendar:read", "calendar:write", "tasks:read"],
    "plaintext": "mk_live_aB3xKp...full token shown ONCE..."
  }
}

Per-contact embed keys (for a client's widget)

Open a contact, expand Embed keys, click Generate new key. The default scope set is ['embed:client'], which authorizes the embed chat and booking endpoints. Each contact can hold many keys; revoked keys remain in the list, line-through, for audit traceability.

Scopes

Scopes are space-free resource:verb tokens stored on the key row as a TEXT[] array. The server checks the required scope on each V1 route via verifyApiKey(key, [required]). The canonical list comes from lib/api-key-types.ts:

calendar:read     View events and availability
calendar:write    Create, update, delete events
tasks:read        View tasks and projects
tasks:write       Create and manage tasks
buckets:read      View time-tracking categories
buckets:write     Create and manage buckets
notes:read        View notes
notes:write       Create and manage notes
time:read         View time entries
time:write        Log time entries
projects:read     View projects
projects:write    Create and manage projects
goals:read        View goals and progress
contacts:read     View contact information
agent:schedule    Natural-language scheduling endpoint
collab:rooms      Create and join collab rooms
collab:sync       Stream messages and files in rooms

Embed keys additionally accept embed:client(omnibus, granted by default at creation), embed:chat (the chat widget endpoint), and embed:book (the booking endpoint). The embed booking route accepts a key when its scopes include either embed:client or embed:book; the chat route follows the same either-or pattern with embed:chat.

Grant the minimum a key needs. A read-only reporting agent should never receive calendar:write. Scope mismatches return 403 with the structured error { error: { code: "FORBIDDEN", ... } }.

Rotating & Revoking

There is no in-place rotation: the plaintext is unrecoverable, so the workflow is create new → deploy → revoke old. Generate a fresh key, ship the new value to the consumer, and once you confirm traffic on the new key:<preview> chip in the audit viewer, click Revoke on the previous one.

Revocation stamps revoked_at on the row. Future calls fail at verifyApiKey() with 401 and a structured { error: { code: "UNAUTHORIZED" } }. The row stays in the table so historical audit entries still resolve their api_key_id back to a preview.

Embed key revocation

Inside the contact slide-over, each active embed key has a Revoke link. The DELETE call hits /api/contacts/[id]/api-keys/[keyId]; the row stays for audit, rendered with a strike-through and the muted-stone treatment.

Audit Log: what gets recorded

Every authenticated request (both V1 API calls and session-authenticated dashboard mutations) writes one row to public.api_audit_log. The insert runs through the service-role client because RLS forbids client INSERT and restricts SELECT to auth.uid() = user_id. Audit failures are silent and never block the response.

Row shape

api_audit_log
  id            BIGSERIAL PRIMARY KEY
  occurred_at   TIMESTAMPTZ DEFAULT now()
  user_id       UUID    -- owning user (auth.users.id)
  api_key_id    UUID    -- NULL for session-auth rows
  route         TEXT    -- e.g. "/api/v1/events"
  method        TEXT    -- GET/POST/PATCH/DELETE
  status_code   INT     -- 200, 400, 401, 403, 404, 409, 500, ...
  latency_ms    INT     -- wall-clock duration
  client_ip     INET    -- from x-forwarded-for / x-real-ip
  user_agent    TEXT
  request_id    TEXT
  metadata      JSONB   -- route-specific; { actor: 'session' } for session rows

The body of the request is not recorded, only its shape. Routes may add their own breadcrumbs to metadata (tool name, target id, error code) but must not put PII there.

recordAuditLog vs recordSessionAudit

Two helpers in lib/api-audit.ts: recordAuditLog() is the primitive used by the V1 API auth middleware, it carries a non-null api_key_id. recordSessionAudit() is a fire-and-forget wrapper for session-authenticated dashboard mutation routes; it sets api_key_id = null and stamps metadata.actor = 'session' so the viewer can chip the row distinctly. As of finish-line iteration i4, 13 mutation routes are wired (bucket × 4, event × 3, task × 3, time-tracker × 2, collaborator invite), capturing every status branch (200/400/401/403/404/409/500).

Retention is 90 days. The SECURITY-DEFINER function prune_api_audit_log() deletes rows older than that and is run by a scheduled job.

Audit Viewer (/settings/audit)

The audit viewer lives at /settings/audit. It is read-only and shows the last 200 entries that match the active filter set, plus a 7-day stats strip (total calls, error rate, p50 / p95 latency, and a per-day volume sparkline with errors overlaid in rose).

Filters

Three filters compose: a route prefix (free-text, e.g. /api/v1/events), an api key picker that lists every key that has ever appeared in your audit log by preview, and an all / errors toggle that pins the status floor at 400. Selecting a key surfaces a chip at the top of the page with a clear button.

Actor chips

The actor column shows where a row came from at a glance:

session              sky-blue chip   metadata.actor === 'session'
                                     and api_key_id IS NULL
                                     (dashboard mutation routes)

key:mk_live_aB3x...  violet chip     api_key_id resolves to a known preview
                                     (V1 API or MCP tool call)

The status column is color-toned: emerald for 2xx, stone for 3xx, amber for 4xx, rose for 5xx. The when column is rendered relatively (3s, 12m, 4h, 2d) so a glance tells you recency.

Configuring Webhooks

Webhooks let your own server react when something happens in Malleable, most commonly, a guest booking through your embed widget. There are two layers: a per-account default and a per-contact override.

Per-account default

Configure at /settings/embed/webhook. Two fields live on profiles: embed_webhook_url and embed_webhook_secret. The URL is a plain HTTPS endpoint; the secret is an HMAC seed generated by clicking Generate (or Rotate if one already exists). The secret is shown once in an amber "copy this, shown once" panel and then only its presence is reported to the UI.

Per-contact override

For agency setups where each client has their own backend, the table customer_embed_configs stores per-contact overrides keyed by contact_id: brand_color, logo_url, greeting, allowed_origin, callback_webhook_url, and callback_webhook_secret. When a booking flows through an embed key tied to a contact, Malleable prefers the per-contact callback over the account-level one.

Rotating the signing secret

Rotation is destructive: the previous secret is overwritten. Plan a brief window where your verifier accepts either secret if you cannot deploy the new one atomically, or rotate during a low-traffic period and accept that in-flight webhooks during the swap will fail verification and re-enter the retry queue.

Webhook Event Types

Each delivery is a JSON object with three top-level fields. The event name travels both in the body and as the x-malleable-event header, so you can route without parsing the body if you prefer.

{
  "event": "booking.created",
  "occurred_at": "2026-05-03T17:42:11.812Z",
  "data": {
    // event-specific payload
  }
}

Currently emitted

booking.created: fired when a guest completes a booking on an embed widget. The data object carries the booked event (id, start, end, summary), the guest's name and email, and the contact_id the booking was attributed to, when an embed key resolved one.

HTTP envelope

POST <your webhook URL>
content-type: application/json
x-malleable-signature: sha256=<hex digest>
x-malleable-event: booking.created
user-agent: malleable-webhook/1

{ "event": "booking.created", "occurred_at": "...", "data": { ... } }

HMAC Signature Verification

Every webhook body is signed with HMAC-SHA256 keyed by your signing secret. The digest is hex-encoded and sent in the x-malleable-signature header, prefixed with sha256=. Verification is constant-time: compare buffers with crypto.timingSafeEqual, never with ===.

Node.js verifier

import crypto from 'node:crypto';

function verify(secret, rawBody, signatureHeader) {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');
  const received = signatureHeader.replace(/^sha256=/, '');
  if (expected.length !== received.length) return false;
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(received, 'hex'),
  );
}

// In your handler — feed the RAW body string, not the parsed JSON:
app.post('/webhooks/malleable', express.raw({ type: '*/*' }), (req, res) => {
  const ok = verify(
    process.env.MALLEABLE_WEBHOOK_SECRET,
    req.body.toString('utf8'),
    req.headers['x-malleable-signature'] || '',
  );
  if (!ok) return res.status(401).end();
  const event = JSON.parse(req.body.toString('utf8'));
  // ... handle event ...
  res.status(200).end();
});

The same routine is exported as verifyWebhookSignature(secret, body, header) from lib/webhooks/dispatch.ts, useful if you run the SDK on Node and want to reuse the canonical implementation.

Always sign and compare against the raw request bytes. Re-serializing parsed JSON will produce a different string (key order, whitespace) and the signature will not match.

Delivery & Retries

Outbound webhooks are persisted to public.webhook_deliveries the moment a triggering event lands, the booking handler does not call your URL synchronously. A cron worker drains the queue and updates each row in place.

Row shape

webhook_deliveries
  id                UUID PRIMARY KEY
  owner_id          UUID    -- the Malleable user who owns the webhook
  contact_id        UUID    -- nullable; set if a per-contact callback was used
  url               TEXT    -- snapshot of the destination at enqueue time
  secret            TEXT    -- snapshot of the signing secret at enqueue time
  event             TEXT    -- e.g. "booking.created"
  payload           JSONB   -- the full event body
  status            TEXT    -- pending | delivering | delivered | failed | dead
  attempts          INT
  last_status_code  INT
  last_error        TEXT
  next_attempt_at   TIMESTAMPTZ
  created_at        TIMESTAMPTZ
  delivered_at      TIMESTAMPTZ

Lifecycle

A new row starts pending with next_attempt_at = now(). The drain worker picks up due rows, flips them to delivering, POSTs to the URL with an 8-second timeout, and:

  • On 2xx: delivered, with delivered_at stamped and last_status_code recorded.
  • On non-2xx or transport error: attempts++, last_error recorded, status returned to pending with next_attempt_at pushed out on exponential backoff.
  • After repeated failures the row is marked dead and stops being retried. Your verifier is rejecting signatures, your endpoint is offline, or the URL is wrong, inspect last_status_code / last_error in the row to triage.

The schema enforces the state machine via a CHECK constraint (pending, delivering, delivered, failed, dead) but the exact backoff curve and dead-letter threshold live in the drain worker, not the migration, consult /api/cron/webhook-dispatch for the authoritative numbers.

Idempotency on your side

A retry can deliver the same event twice if your endpoint took long enough that our 8-second timeout fired but you eventually returned 200. De-duplicate on data.id (or the relevant resource id inside data) plus event, and treat repeats as no-ops.

Retention

Delivered and dead rows older than 30 days are pruned by prune_webhook_deliveries(). Pending and failed rows are kept until they resolve.