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
modefield.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.modeflows through events. Webhook payloads carrymetadata.mode: "test", and test events are only delivered to webhook endpoints created with a test key.- Workspace usage is live-only.
GET /v1/workspacereports usage filtered tomode='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:
message.opened(with probabilityopen_probability)message.clicked(conditional on a prior open, with probabilityclick_probability)message.replied(with probabilityreply_probability) — creates a real inboundMessagerow in the same threadmessage.bounced(with probabilitybounce_probability) — flips the contact toglobal_status: "bounced"and emitscontact.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.