Guide

Test mode

Sandbox keys, sandbox inboxes, and synthetic events for end-to-end integration tests.

Test mode lets you exercise the full API surface — including end-to-end webhook delivery — without sending real email to real recipients. Sandbox inboxes short-circuit the send pipeline at the worker, and a synthetic event scheduler fans out realistic message.opened / message.clicked / message.replied events afterwards. Test-mode contacts, sends, and events live alongside live data in the same workspace, tagged mode: "test" and invisible to live keys.

How modes are kept apart

  • Keys come in two flavors. cl_live_… is production; cl_test_… is sandbox. The SDK warns when one process mixes the two.
  • Every resource carries a mode field. inbox.mode, contact.mode, send.mode, etc. Reads and writes are scoped to the key’s mode — a live key cannot see test data, and a test key cannot see live data.
  • Pipeline-level guard. Before any provider call, the worker checks send.mode === "live". A test-mode send never reaches Gmail / Outlook / SMTP, so a misrouted key cannot accidentally email a real prospect.
  • metadata.mode flows through events. Webhook payloads carry metadata.mode: "test", and test events are only delivered to webhook endpoints created with a test key.
  • Workspace usage is live-only. GET /v1/workspace reports usage filtered to mode='live' — test activity is free and never counts against quota.

End-to-end walkthrough

1. Mint a test-mode key

In the dashboard, or via the API with an existing admin key:

import { Clement } from "@clement/sdk";
const admin = new Clement({ apiKey: process.env.CLEMENT_ADMIN_KEY! });

const { secret } = await admin.apiKeys.create({
  name: "integration tests",
  scope: "admin",
  mode: "test",
});
// secret looks like cl_test_…  store it in your test env

The secret is shown once. Stash it in your test runner’s secrets — CLEMENT_TEST_API_KEY is the convention.

2. Connect a sandbox inbox

Sandbox inboxes don’t talk to any provider. They accept sends and emit synthetic engagement events on a schedule you configure.

const c = new Clement({ apiKey: process.env.CLEMENT_TEST_API_KEY! });

const inbox = await c.inboxes.createSandbox({
  display_name: "test",
  sandbox_config: {
    open_probability: 1,
    reply_probability: 0.5,
  },
});

sandbox_config accepts open_probability, click_probability, reply_probability, bounce_probability, plus per-event delay windows (*_delay_seconds_min / _max). Set bounce_probability if you want to exercise unsubscribe / suppression paths. Defaults give you a realistic mix; override only what your test cares about.

3. Create a contact

const contact = await c.contacts.create({ email: "alice@example.com" });
// contact.mode === "test" — invisible to live keys in the same workspace

The contact is tagged mode: "test". It coexists with a live alice@example.com if one already exists; the two are independent rows.

4. Fire a send

const send = await c.sends.create(
  {
    inbox_id: inbox.id,
    to: [{ email: "alice@example.com" }],
    subject: "Hello from test mode",
    body_text: "This will never leave the building.",
  },
  { idempotencyKey: crypto.randomUUID() },
);

The worker recognizes the sandbox inbox, skips the provider call, and flips the send straight to sent — send.scheduled and send.sent fire immediately, both carrying metadata.mode: "test".

5. Wait for synthetic events

Over the next ~30s–10m (configurable via the delay windows in sandbox_config), the synthetic event scheduler fans out:

  1. message.opened (with probability open_probability)
  2. message.clicked (conditional on a prior open, with probability click_probability)
  3. message.replied (with probability reply_probability) — creates a real inbound Message row in the same thread
  4. message.bounced (with probability bounce_probability) — flips the contact to global_status: "bounced" and emits contact.bounced

Each event is delivered to every test-mode webhook endpoint in the workspace subscribed to it, signed exactly the way live events are. Webhook endpoints take the mode of the key that created them: register your test endpoint with the cl_test_… key and it receives only test events, while live endpoints (including every endpoint created in the dashboard) never receive them. Payloads still carry metadata.mode. Test endpoints may use http:// (e.g. a local tunnel); live ones must use https://.

6. Clean up

await c.inboxes.delete(inbox.id, { confirm: true });
await c.apiKeys.delete(keyId, { confirm: true });

Both ops require ?confirm=true because they’re destructive. Test-mode contacts, sends, and events stay around — they’re cheap and useful for replaying the same scenario.

Why it matters for agents

LLM tool calls accidentally mixing test and live keys is a real failure mode. The visual distinction (cl_test_… vs cl_live_…), the SDK warning, and the pipeline-level mode check exist specifically so an agent can’t email a real customer because it grabbed the wrong env var. If you’re building agent integrations, run them against a test key in CI — the contract is identical, the blast radius is zero.