mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-07 16:02:16 +00:00
585 lines
37 KiB
Plaintext
585 lines
37 KiB
Plaintext
---
|
|
import Layout from '../layouts/Layout.astro';
|
|
import HeroAtmosphere from '../components/HeroAtmosphere.astro';
|
|
import Icon from '../components/Icon.astro';
|
|
import CTA from '../components/CTA.astro';
|
|
|
|
// All numbers below are platform-level facts from the codebase:
|
|
// internal/api/handler/*, internal/app/ratelimit/service.go,
|
|
// internal/api/middleware/ratelimit.go, internal/app/consumer/*,
|
|
// internal/app/advanced/service.go.
|
|
|
|
// =========================================================================
|
|
// Section 2 · tabbed client editor
|
|
// =========================================================================
|
|
type Client = { id: string; label: string };
|
|
const clients: Client[] = [
|
|
{ id: 'ts', label: 'TypeScript' },
|
|
{ id: 'py', label: 'Python' },
|
|
{ id: 'go', label: 'Go' },
|
|
{ id: 'curl', label: 'curl' },
|
|
];
|
|
|
|
// =========================================================================
|
|
// Section 3 · event catalog, colored per surface
|
|
// =========================================================================
|
|
type EventGroup = {
|
|
surface: string;
|
|
blurb: string;
|
|
events: string[];
|
|
tone: string;
|
|
ring: string; // tailwind ring color for the card border accent
|
|
eyebrow: string; // tailwind classes for eyebrow text
|
|
pill: string; // tailwind classes for the count pill
|
|
chip: string; // tailwind classes for individual event chips
|
|
};
|
|
|
|
// Activity strip bars for the dashboard mock (24 buckets, last 24h).
|
|
const buckets = [
|
|
4, 6, 5, 8, 10, 14, 18, 22, 28, 31, 27, 24,
|
|
29, 36, 41, 38, 33, 30, 26, 22, 19, 15, 11, 9,
|
|
];
|
|
const maxBucket = 41;
|
|
|
|
// Event catalog from the consumer + advanced/outreach services.
|
|
const events = [
|
|
{ name: 'reply.received', body: 'A new reply landed on a sequenced thread.' },
|
|
{ name: 'reply.classified', body: 'Reply has been categorised as positive, neutral, objection, or unsubscribe.' },
|
|
{ name: 'bounce.hard', body: 'A delivery hard-bounced. The recipient is suppressed workspace-wide.' },
|
|
{ name: 'bounce.soft', body: 'A delivery soft-bounced. Three on the same domain auto-suppress.' },
|
|
{ name: 'complaint.received', body: 'A recipient marked a message as spam at the provider.' },
|
|
{ name: 'unsubscribe.received', body: 'A one-click unsubscribe was processed for a recipient.' },
|
|
{ name: 'mailbox.connected', body: 'A mailbox completed OAuth and started its first warmup pass.' },
|
|
{ name: 'mailbox.quarantined', body: 'A mailbox crossed a health band and was removed from the shared pool.' },
|
|
{ name: 'mailbox.recovered', body: 'A mailbox passed re-entry probation and is healthy again.' },
|
|
{ name: 'sequence.started', body: 'A recipient entered a sequence and is queued for the first step.' },
|
|
{ name: 'sequence.completed', body: 'A recipient reached the last step without replying.' },
|
|
{ name: 'campaign.paused', body: 'A campaign was paused by the scheduler or an operator.' },
|
|
{ name: 'contact.created', body: 'A new contact was created by API, import, or CRM enrichment.' },
|
|
{ name: 'deal.stage_changed', body: 'A deal moved between pipeline stages.' },
|
|
];
|
|
|
|
// Rate limit + scope spec sheet.
|
|
const limits = [
|
|
{ k: 'Default per-key limit', v: '600 / min', why: 'Set on key creation. Bumps available on request after a 7-day baseline.' },
|
|
{ k: 'Read-only key limit', v: '120 / min', why: 'Lower default for read-scoped keys used by dashboards and pollers.' },
|
|
{ k: 'Webhook delivery retries', v: '5 attempts', why: 'Exponential backoff at 30s, 2m, 10m, 1h, 6h. After 5 fails the event lands in the dead-letter view.' },
|
|
{ k: 'Webhook delivery timeout', v: '10s', why: 'Receivers that take longer are treated as failed and retried. Return 2xx fast then process async.' },
|
|
{ k: 'Idempotency-Key window', v: '24h', why: 'Identical write requests within the window are deduplicated. Stripe-compatible semantics.' },
|
|
{ k: 'Event payload max', v: '256 KB', why: 'Webhook bodies above this size are truncated and the full payload is fetchable via the events API.' },
|
|
];
|
|
|
|
const scopes = [
|
|
{ k: 'mailboxes:read', why: 'List, inspect, fetch health snapshots. No writes.' },
|
|
{ k: 'mailboxes:write', why: 'Connect, pause, delete. Trigger warmup re-evaluation.' },
|
|
{ k: 'sequences:read', why: 'Inspect sequences, steps, variants, and per-recipient state.' },
|
|
{ k: 'sequences:write', why: 'Create, edit, version, pause, and resume sequences.' },
|
|
{ k: 'contacts:read', why: 'Query contacts, lists, custom fields, and suppression entries.' },
|
|
{ k: 'contacts:write', why: 'Create, update, import, and suppress contacts.' },
|
|
{ k: 'events:read', why: 'Subscribe to the firehose or replay historical events.' },
|
|
{ k: 'crm:read', why: 'Read pipelines, deals, stages, and tasks.' },
|
|
{ k: 'crm:write', why: 'Move deals, edit pipelines, complete tasks.' },
|
|
{ k: 'admin:write', why: 'Workspace-level settings. Granted explicitly per key.' },
|
|
];
|
|
|
|
const faq = [
|
|
['How do I verify a webhook signature?', 'Compute HMAC-SHA256 of the raw request body using your endpoint secret. Compare the hex digest to the X-Warmbly-Signature header in constant time. Reject any request older than 5 minutes against X-Warmbly-Timestamp.'],
|
|
['Are webhooks delivered exactly once?', 'No. We deliver at least once. Every event carries an idempotency_key. Persist it on first receipt and skip duplicates. Retries happen on any non-2xx response or a timeout above 10 seconds.'],
|
|
['What happens after 5 retries?', 'The event lands in the dead-letter view in the dashboard. From there you can inspect the last failure, replay one event or a range, or rotate the endpoint secret without resetting delivery history.'],
|
|
['Can a single key be scoped to one campaign or mailbox?', 'Yes. Each key carries a list of scopes and an optional resource filter, for example mailboxes:write limited to mbx_01HQX. Cross-resource calls return 403 with a scope_required error code.'],
|
|
];
|
|
---
|
|
<Layout
|
|
title="Developer API & Webhooks | Warmbly"
|
|
description="REST API with idempotency keys, scoped keys, HMAC-signed webhooks, and a dead-letter view. The same surface the dashboard runs on."
|
|
>
|
|
<!-- ============================================================
|
|
HERO · HeroAtmosphere, same as home / pricing / warmup / campaigns
|
|
============================================================ -->
|
|
<section class="relative isolate overflow-hidden">
|
|
<HeroAtmosphere />
|
|
|
|
<div class="container-page relative pt-16 md:pt-24 pb-44 md:pb-56 text-center">
|
|
<div class="inline-flex items-center gap-2 h-7 pl-1 pr-3 rounded-full bg-white/15 backdrop-blur ring-1 ring-white/25 text-[12px] text-white/95 shadow-[0_4px_14px_-4px_rgba(0,0,0,0.25)]">
|
|
<span class="inline-flex items-center h-5 px-1.5 rounded-full text-[10.5px] font-semibold uppercase tracking-[0.06em] bg-white" style="color:#0369a1;">
|
|
API & webhooks
|
|
</span>
|
|
The same surface our dashboard runs on
|
|
</div>
|
|
|
|
<h1 class="mt-8 md:mt-10 text-[44px] sm:text-6xl md:text-[72px] lg:text-[80px] font-semibold tracking-[-0.04em] leading-[0.98] text-white max-w-4xl mx-auto">
|
|
REST in.<br/>Signed events out.
|
|
</h1>
|
|
<p class="mt-6 text-[17px] md:text-[19px] text-white/80 max-w-2xl mx-auto leading-relaxed">
|
|
Scoped keys with per-minute limits. Idempotent writes. HMAC-signed webhooks with five retries and a dead-letter view. No hidden surface, no second-class API.
|
|
</p>
|
|
|
|
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
<a href="https://app.warmbly.com/settings/api-keys" class="inline-flex h-11 px-5 items-center gap-2 rounded-[10px] text-[14.5px] font-semibold bg-white hover:bg-white hover:-translate-y-0.5 hover:shadow-[0_12px_28px_-6px_rgba(0,0,0,0.3)] transition-all duration-200 ease-out shadow-[0_4px_14px_-2px_rgba(0,0,0,0.18)]">
|
|
<span style="color:#075985;">Get an API key</span>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#075985" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>
|
|
</svg>
|
|
</a>
|
|
<a href="https://docs.warmbly.com" class="inline-flex h-11 px-5 items-center rounded-[10px] text-[14.5px] font-medium bg-white/10 backdrop-blur ring-1 ring-white/40 text-white hover:bg-white/20 hover:-translate-y-0.5 transition-all duration-200 ease-out">
|
|
Read the docs
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================
|
|
FLOATING DASHBOARD MOCK · mirrors the real api-keys page
|
|
============================================================ -->
|
|
<section class="relative -mt-36 md:-mt-44 pb-16 md:pb-24 z-10">
|
|
<div class="container-page">
|
|
<div class="rounded-[18px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden shadow-[0_40px_90px_-30px_rgba(2,32,71,0.35),0_12px_30px_-12px_rgba(15,23,42,0.12)]">
|
|
|
|
<!-- Topbar -->
|
|
<div class="px-6 py-3 border-b border-[color:var(--border)] flex items-baseline justify-between gap-3">
|
|
<div class="flex items-baseline gap-3">
|
|
<div class="text-[10.5px] font-mono uppercase tracking-[0.18em] text-muted-foreground">API keys</div>
|
|
<span class="text-[11.5px] text-foreground/55">Programmatic access · scoped tokens</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<div class="h-7 px-2 rounded-md ring-1 ring-[color:var(--border)] bg-white flex items-center gap-1.5">
|
|
<Icon name="search" size={11} class="text-foreground/40" />
|
|
<span class="text-[11px] text-foreground/40">Search</span>
|
|
</div>
|
|
<span class="inline-flex items-center h-7 px-3 rounded-md text-[11.5px] font-semibold text-white bg-[#0284c7] gap-1.5">
|
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
|
Create key
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stat strip -->
|
|
<div class="grid grid-cols-2 md:grid-cols-4 divide-x divide-[color:var(--border)] border-b border-[color:var(--border)]">
|
|
{[
|
|
{ l: 'Active keys', v: '4', s: '1 revoked', accent: true },
|
|
{ l: 'Requests · 24h', v: '482,113', s: '0.04% errors' },
|
|
{ l: 'Avg latency · 24h', v: '38ms', s: 'across all keys' },
|
|
{ l: 'Last call', v: '14s ago', s: 'May 27, 10:42' },
|
|
].map((s) => (
|
|
<div class="px-6 py-4">
|
|
<div class="text-[10px] uppercase tracking-[0.18em] font-mono text-muted-foreground">{s.l}</div>
|
|
<div class={`mt-1.5 text-[22px] font-semibold tracking-[-0.02em] font-mono ${s.accent ? 'text-[#0369a1]' : 'text-heading'}`}>{s.v}</div>
|
|
<div class="mt-0.5 text-[11px] font-mono text-muted-foreground">{s.s}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<!-- Section bar: traffic -->
|
|
<div class="px-6 py-2.5 border-b border-[color:var(--border)] bg-[color:var(--surface-1)]/60 flex items-baseline justify-between">
|
|
<div class="text-[10.5px] uppercase tracking-[0.18em] font-mono text-foreground/70">Traffic · last 24h</div>
|
|
<div class="text-[10.5px] font-mono text-muted-foreground">482,113 calls</div>
|
|
</div>
|
|
<div class="px-6 py-4 border-b border-[color:var(--border)]">
|
|
<div class="flex items-end gap-1 h-[80px]">
|
|
{buckets.map((b, i) => {
|
|
const h = Math.max(4, Math.round((b / maxBucket) * 100));
|
|
const isHot = b > 28;
|
|
return (
|
|
<div class="flex-1 rounded-t-[2px]" style={`height: ${h}%; background: ${isHot ? '#0284c7' : '#7dd3fc'};`} title={`bucket ${i}`}></div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div class="mt-2 flex items-center gap-3 text-[10.5px] font-mono text-muted-foreground">
|
|
<span class="inline-flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-sm bg-emerald-500/80"></span>2xx</span>
|
|
<span class="inline-flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-sm bg-amber-400/80"></span>4xx</span>
|
|
<span class="inline-flex items-center gap-1"><span class="w-1.5 h-1.5 rounded-sm bg-rose-500/80"></span>5xx</span>
|
|
<span class="ml-auto">refreshes every minute</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Section bar: keys -->
|
|
<div class="px-6 py-2.5 border-b border-[color:var(--border)] bg-[color:var(--surface-1)]/60 flex items-baseline justify-between">
|
|
<div class="text-[10.5px] uppercase tracking-[0.18em] font-mono text-foreground/70">Keys</div>
|
|
<div class="text-[10.5px] font-mono text-muted-foreground">{keys.length} total</div>
|
|
</div>
|
|
|
|
<!-- Keys table -->
|
|
<div class="divide-y divide-[color:var(--border)]">
|
|
{keys.map((k) => {
|
|
const tone = statusTone(k.status);
|
|
return (
|
|
<div class="grid grid-cols-[24px_minmax(0,1.2fr)_minmax(0,1.4fr)_72px_70px_1fr] gap-3 items-center px-6 py-3 hover:bg-[color:var(--surface-1)]/60 transition-colors">
|
|
<Icon name="key" size={13} class={k.status === 'active' ? 'text-foreground/55' : 'text-foreground/25'} />
|
|
<div class="min-w-0">
|
|
<div class="text-[12.5px] font-medium text-heading truncate">{k.name}</div>
|
|
<div class="text-[10.5px] font-mono text-muted-foreground truncate">scope: {k.scope}</div>
|
|
</div>
|
|
<div class="text-[11px] font-mono text-foreground/60 truncate">{k.prefix}…{k.suffix}</div>
|
|
<span class={`inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] uppercase tracking-[0.08em] font-medium ${tone.bg} ${tone.text}`}>
|
|
<span class={`w-1 h-1 rounded-full ${tone.dot}`}></span>
|
|
{k.status}
|
|
</span>
|
|
<div class="text-[10.5px] font-mono text-muted-foreground tabular-nums">{k.rpm}/m</div>
|
|
<div class="text-[10.5px] font-mono text-muted-foreground tabular-nums text-right truncate">used {k.last}</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div class="mt-3 text-center text-[11.5px] font-mono text-muted-foreground">
|
|
app.warmbly.com/settings/api-keys
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================
|
|
HELLO WORLD · code panels stacked, curl + JS + Python
|
|
============================================================ -->
|
|
<section class="border-y border-[color:var(--border)] py-20 md:py-28">
|
|
<div class="container-page">
|
|
<div class="grid lg:grid-cols-[1fr_1.5fr] gap-12 lg:gap-16 items-start">
|
|
<div class="lg:sticky lg:top-24" style="align-self: start;">
|
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Hello world</div>
|
|
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
|
Three lines to the first call.
|
|
</h2>
|
|
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed max-w-md">
|
|
Pass the secret as a Bearer header. Every response returns rate-limit headers so well-behaved clients self-throttle before they hit 429.
|
|
</p>
|
|
<ul class="mt-6 space-y-2 text-[13.5px] text-foreground/80">
|
|
<li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Pass an Idempotency-Key on writes to make retries safe.</span></li>
|
|
<li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Read X-RateLimit-Remaining and back off on 429.</span></li>
|
|
<li class="flex items-start gap-2"><Icon name="check" size={13} class="text-[#0284c7] mt-1 shrink-0" /><span>Errors return a stable code so you can switch on it.</span></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<!-- curl -->
|
|
<div class="rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]">
|
|
<div class="flex items-center gap-2 px-4 py-2.5 bg-[#0c1224] border-b border-white/10">
|
|
<span class="w-2.5 h-2.5 rounded-full bg-[#ff5f56]/80"></span>
|
|
<span class="w-2.5 h-2.5 rounded-full bg-[#ffbd2e]/80"></span>
|
|
<span class="w-2.5 h-2.5 rounded-full bg-[#27c93f]/80"></span>
|
|
<span class="ml-3 text-[11.5px] font-mono text-white/60">terminal · curl</span>
|
|
</div>
|
|
<pre class="bg-[#0c1224] text-white/85 p-5 text-[12.5px] leading-[1.65] font-mono overflow-x-auto"><code><span style="color:#94a3b8">$</span> curl <span style="color:#fde68a">https://api.warmbly.com/v1/mailboxes</span> \
|
|
-H <span style="color:#fde68a">"Authorization: Bearer $WARMBLY_KEY"</span> \
|
|
-H <span style="color:#fde68a">"Idempotency-Key: $(uuidgen)"</span>
|
|
|
|
<span style="color:#a78bfa">HTTP/2 200</span>
|
|
<span style="color:#7dd3fc">x-ratelimit-limit</span>: 600
|
|
<span style="color:#7dd3fc">x-ratelimit-remaining</span>: 599
|
|
<span style="color:#7dd3fc">x-ratelimit-reset</span>: 1746820392
|
|
<span style="color:#7dd3fc">x-request-id</span>: req_01HQX9F7P3A8KY2NJM4R6BWT0S
|
|
|
|
{
|
|
<span style="color:#7dd3fc">"data"</span>: [
|
|
{
|
|
<span style="color:#7dd3fc">"id"</span>: <span style="color:#fde68a">"mbx_01HQX..."</span>,
|
|
<span style="color:#7dd3fc">"address"</span>: <span style="color:#fde68a">"ben@acme.com"</span>,
|
|
<span style="color:#7dd3fc">"health"</span>: <span style="color:#fde68a">"healthy"</span>,
|
|
<span style="color:#7dd3fc">"cold_cap_per_day"</span>: <span style="color:#a78bfa">100</span>
|
|
}
|
|
],
|
|
<span style="color:#7dd3fc">"has_more"</span>: <span style="color:#a78bfa">false</span>
|
|
}</code></pre>
|
|
</div>
|
|
|
|
<!-- TypeScript -->
|
|
<div class="rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]">
|
|
<div class="flex items-center gap-2 px-4 py-2.5 bg-[#0c1224] border-b border-white/10">
|
|
<span class="w-2.5 h-2.5 rounded-full bg-[#ff5f56]/80"></span>
|
|
<span class="w-2.5 h-2.5 rounded-full bg-[#ffbd2e]/80"></span>
|
|
<span class="w-2.5 h-2.5 rounded-full bg-[#27c93f]/80"></span>
|
|
<span class="ml-3 text-[11.5px] font-mono text-white/60">add-mailbox.ts</span>
|
|
</div>
|
|
<pre class="bg-[#0c1224] text-white/85 p-5 text-[12.5px] leading-[1.65] font-mono overflow-x-auto"><code><span style="color:#7dd3fc">import</span> Warmbly <span style="color:#7dd3fc">from</span> <span style="color:#fde68a">"@warmbly/sdk"</span>;
|
|
|
|
<span style="color:#7dd3fc">const</span> wb <span style="color:#f472b6">=</span> <span style="color:#7dd3fc">new</span> <span style="color:#a78bfa">Warmbly</span>({ apiKey: process.env.WARMBLY_KEY! });
|
|
|
|
<span style="color:#7dd3fc">const</span> mailbox <span style="color:#f472b6">=</span> <span style="color:#7dd3fc">await</span> wb.mailboxes.<span style="color:#a78bfa">create</span>({
|
|
address: <span style="color:#fde68a">"sara@acme.com"</span>,
|
|
provider: <span style="color:#fde68a">"google"</span>,
|
|
warmup: { pool: <span style="color:#fde68a">"premium"</span> },
|
|
}, { idempotencyKey: <span style="color:#fde68a">"sara-bootstrap-2026-05"</span> });
|
|
|
|
<span style="color:#94a3b8">// Returns the saved mailbox, ready for OAuth handoff.</span>
|
|
console.<span style="color:#a78bfa">log</span>(mailbox.id, mailbox.oauth_url);</code></pre>
|
|
</div>
|
|
|
|
<!-- Python -->
|
|
<div class="rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]">
|
|
<div class="flex items-center gap-2 px-4 py-2.5 bg-[#0c1224] border-b border-white/10">
|
|
<span class="w-2.5 h-2.5 rounded-full bg-[#ff5f56]/80"></span>
|
|
<span class="w-2.5 h-2.5 rounded-full bg-[#ffbd2e]/80"></span>
|
|
<span class="w-2.5 h-2.5 rounded-full bg-[#27c93f]/80"></span>
|
|
<span class="ml-3 text-[11.5px] font-mono text-white/60">handle_reply.py</span>
|
|
</div>
|
|
<pre class="bg-[#0c1224] text-white/85 p-5 text-[12.5px] leading-[1.65] font-mono overflow-x-auto"><code><span style="color:#7dd3fc">from</span> warmbly <span style="color:#7dd3fc">import</span> Warmbly
|
|
|
|
wb <span style="color:#f472b6">=</span> <span style="color:#a78bfa">Warmbly</span>(api_key<span style="color:#f472b6">=</span>os.environ[<span style="color:#fde68a">"WARMBLY_KEY"</span>])
|
|
|
|
<span style="color:#94a3b8"># Re-classify the most recent positive replies on a sequence.</span>
|
|
<span style="color:#7dd3fc">for</span> ev <span style="color:#7dd3fc">in</span> wb.events.<span style="color:#a78bfa">list</span>(
|
|
type<span style="color:#f472b6">=</span><span style="color:#fde68a">"reply.classified"</span>,
|
|
classification<span style="color:#f472b6">=</span><span style="color:#fde68a">"positive"</span>,
|
|
sequence_id<span style="color:#f472b6">=</span><span style="color:#fde68a">"seq_01HQX..."</span>,
|
|
limit<span style="color:#f472b6">=</span><span style="color:#a78bfa">50</span>,
|
|
):
|
|
crm.<span style="color:#a78bfa">log_intent</span>(ev.data.contact_id, ev.data.preview)</code></pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================
|
|
WEBHOOKS · split signed payload + verification semantics
|
|
============================================================ -->
|
|
<section class="bg-[color:var(--surface-1)]/40 border-b border-[color:var(--border)] py-20 md:py-28">
|
|
<div class="container-page">
|
|
<div class="max-w-3xl mb-12">
|
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Webhook delivery</div>
|
|
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
|
Signed bodies. Replay-safe. Dead-letter visible.
|
|
</h2>
|
|
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">
|
|
Every event is HMAC-signed with your endpoint secret and carries an idempotency key. Failures retry five times on an exponential backoff. After the fifth failure the event lands in the dead-letter view, where you can inspect the response and replay one event or a range.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid lg:grid-cols-[1.25fr_1fr] gap-6">
|
|
<!-- Payload + verification code -->
|
|
<div class="rounded-[14px] overflow-hidden ring-1 ring-[color:var(--border)]">
|
|
<div class="px-5 py-2.5 bg-[#0c1224] border-b border-white/10 flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<span class="w-2.5 h-2.5 rounded-full bg-[#ff5f56]/80"></span>
|
|
<span class="w-2.5 h-2.5 rounded-full bg-[#ffbd2e]/80"></span>
|
|
<span class="w-2.5 h-2.5 rounded-full bg-[#27c93f]/80"></span>
|
|
</div>
|
|
<span class="text-[11px] font-mono text-white/55">POST /webhooks/warmbly · reply.received</span>
|
|
</div>
|
|
<pre class="bg-[#0c1224] text-white/85 p-5 text-[12px] leading-[1.65] font-mono overflow-x-auto"><code><span style="color:#7dd3fc">X-Warmbly-Signature</span>: t=1748340453,v1=8e3c<span style="color:#94a3b8">…</span>a17f
|
|
<span style="color:#7dd3fc">X-Warmbly-Timestamp</span>: 1748340453
|
|
<span style="color:#7dd3fc">X-Warmbly-Event-Id</span>: evt_01HQX9F7P3A8KY2NJM4R6BWT0S
|
|
<span style="color:#7dd3fc">X-Warmbly-Attempt</span>: 1
|
|
|
|
{
|
|
<span style="color:#7dd3fc">"id"</span>: <span style="color:#fde68a">"evt_01HQX9F7P3A8KY2NJM4R6BWT0S"</span>,
|
|
<span style="color:#7dd3fc">"type"</span>: <span style="color:#fde68a">"reply.received"</span>,
|
|
<span style="color:#7dd3fc">"created_at"</span>: <span style="color:#fde68a">"2026-05-27T16:01:33Z"</span>,
|
|
<span style="color:#7dd3fc">"workspace_id"</span>: <span style="color:#fde68a">"ws_acme"</span>,
|
|
<span style="color:#7dd3fc">"idempotency_key"</span>: <span style="color:#fde68a">"reply:th_01HQX..."</span>,
|
|
<span style="color:#7dd3fc">"data"</span>: {
|
|
<span style="color:#7dd3fc">"mailbox_id"</span>: <span style="color:#fde68a">"mbx_01HQX..."</span>,
|
|
<span style="color:#7dd3fc">"sequence_id"</span>: <span style="color:#fde68a">"seq_01HQX..."</span>,
|
|
<span style="color:#7dd3fc">"contact_id"</span>: <span style="color:#fde68a">"ct_01HQX..."</span>,
|
|
<span style="color:#7dd3fc">"classification"</span>: <span style="color:#fde68a">"positive"</span>,
|
|
<span style="color:#7dd3fc">"preview"</span>: <span style="color:#fde68a">"Sure, send a calendar invite for Thursday."</span>
|
|
}
|
|
}
|
|
|
|
<span style="color:#94a3b8"># Verify in your handler (Node example).</span>
|
|
<span style="color:#7dd3fc">import</span> { createHmac, timingSafeEqual } <span style="color:#7dd3fc">from</span> <span style="color:#fde68a">"node:crypto"</span>;
|
|
|
|
<span style="color:#7dd3fc">function</span> <span style="color:#a78bfa">verify</span>(rawBody, header, secret) {
|
|
<span style="color:#7dd3fc">const</span> [t, v1] <span style="color:#f472b6">=</span> header.<span style="color:#a78bfa">split</span>(<span style="color:#fde68a">","</span>).<span style="color:#a78bfa">map</span>(p <span style="color:#f472b6">=></span> p.<span style="color:#a78bfa">split</span>(<span style="color:#fde68a">"="</span>)[1]);
|
|
<span style="color:#7dd3fc">const</span> signed <span style="color:#f472b6">=</span> <span style="color:#fde68a">`${t}.${rawBody}`</span>;
|
|
<span style="color:#7dd3fc">const</span> mac <span style="color:#f472b6">=</span> <span style="color:#a78bfa">createHmac</span>(<span style="color:#fde68a">"sha256"</span>, secret).<span style="color:#a78bfa">update</span>(signed).<span style="color:#a78bfa">digest</span>(<span style="color:#fde68a">"hex"</span>);
|
|
<span style="color:#7dd3fc">return</span> <span style="color:#a78bfa">timingSafeEqual</span>(Buffer.<span style="color:#a78bfa">from</span>(mac), Buffer.<span style="color:#a78bfa">from</span>(v1));
|
|
}</code></pre>
|
|
</div>
|
|
|
|
<!-- Retry policy panel -->
|
|
<div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden">
|
|
<div class="px-5 py-3 border-b border-[color:var(--border)] text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground">Retry schedule</div>
|
|
<div class="divide-y divide-[color:var(--border)]">
|
|
{[
|
|
{ n: '01', when: '+30 seconds', desc: 'First retry after transient failures. Most outages resolve here.' },
|
|
{ n: '02', when: '+2 minutes', desc: 'Second attempt. Receivers that throttle briefly recover by now.' },
|
|
{ n: '03', when: '+10 minutes', desc: 'Third attempt. Survives short rolling restarts of your receiver.' },
|
|
{ n: '04', when: '+1 hour', desc: 'Fourth attempt. Covers planned maintenance windows.' },
|
|
{ n: '05', when: '+6 hours', desc: 'Last attempt. After this the event moves to the dead-letter view.' },
|
|
].map((r) => (
|
|
<div class="grid grid-cols-[40px_110px_1fr] gap-3 px-5 py-3.5 items-baseline">
|
|
<span class="font-mono text-[11px] text-[#0284c7] font-semibold">{r.n}</span>
|
|
<span class="text-[12.5px] font-mono text-heading font-semibold">{r.when}</span>
|
|
<span class="text-[12.5px] text-foreground/70 leading-snug">{r.desc}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div class="px-5 py-3 border-t border-[color:var(--border)] bg-[color:var(--surface-1)]/60 flex items-center justify-between text-[11.5px] font-mono">
|
|
<span class="text-muted-foreground">After 5 failed attempts</span>
|
|
<span class="inline-flex items-center gap-1 text-rose-700"><span class="w-1 h-1 rounded-full bg-rose-500"></span> dead-letter</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================
|
|
EVENT CATALOG · 2-col grid of subscribable event types
|
|
============================================================ -->
|
|
<section class="border-b border-[color:var(--border)] py-20 md:py-28">
|
|
<div class="container-page">
|
|
<div class="max-w-2xl mb-12">
|
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Event catalog</div>
|
|
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
|
Every meaningful state change, addressable.
|
|
</h2>
|
|
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed">
|
|
Subscribe to the firehose, filter by type, or scope to a single sequence or mailbox. The same event the dashboard reads is the event you receive, in the same shape.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid md:grid-cols-2 gap-px bg-[color:var(--border)] rounded-[12px] overflow-hidden ring-1 ring-[color:var(--border)]">
|
|
{events.map((e) => (
|
|
<div class="bg-white px-6 py-4">
|
|
<div class="flex items-baseline justify-between gap-3">
|
|
<div class="font-mono text-[13px] text-[#0369a1] font-semibold">{e.name}</div>
|
|
<span class="inline-flex items-center gap-1 h-5 px-1.5 rounded text-[10px] font-mono text-emerald-700 bg-emerald-50">
|
|
<span class="w-1 h-1 rounded-full bg-emerald-500"></span>
|
|
subscribable
|
|
</span>
|
|
</div>
|
|
<p class="mt-1.5 text-[13px] text-foreground/65 leading-snug">{e.body}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div class="mt-6 text-[11.5px] font-mono text-muted-foreground">
|
|
Source: <span class="text-foreground/75">internal/app/consumer</span> · <span class="text-foreground/75">internal/app/advanced/service.go</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================
|
|
LIMITS · 2-column spec sheet (pattern from warmup / campaigns)
|
|
============================================================ -->
|
|
<section class="py-20 md:py-28">
|
|
<div class="container-page">
|
|
<div class="max-w-2xl mb-12">
|
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Limits and quotas</div>
|
|
<h2 class="text-[32px] md:text-[44px] font-semibold tracking-[-0.03em] leading-[1.05] text-heading">
|
|
The numbers the platform enforces.
|
|
</h2>
|
|
<p class="mt-4 text-[15.5px] text-foreground/70 leading-relaxed">
|
|
Hard limits in the codebase. Per-key overrides are possible after a 7-day baseline. The right column is the reasoning we will defend in an incident review.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="grid md:grid-cols-2 gap-x-12 gap-y-px">
|
|
{limits.map((d, i) => (
|
|
<div class="grid grid-cols-[1fr_auto] gap-6 py-6 border-b border-[color:var(--border)] items-baseline hover:bg-[color:var(--surface-1)]/40 -mx-3 px-3 rounded-[6px] transition-colors">
|
|
<div>
|
|
<div class="text-[10.5px] uppercase tracking-[0.22em] font-mono text-muted-foreground">
|
|
{String(i + 1).padStart(2, '0')} · {d.k}
|
|
</div>
|
|
<p class="mt-2 text-[13.5px] text-foreground/65 leading-relaxed max-w-md">{d.why}</p>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="text-[22px] md:text-[26px] font-semibold tracking-[-0.02em] text-heading font-mono whitespace-nowrap">{d.v}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div class="mt-8 text-[11.5px] font-mono text-muted-foreground">
|
|
Source: <span class="text-foreground/75">internal/app/ratelimit/service.go</span> · <span class="text-foreground/75">internal/api/middleware/ratelimit.go</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================
|
|
SCOPES · per-key scope catalog (paired with limits visually)
|
|
============================================================ -->
|
|
<section class="border-y border-[color:var(--border)] bg-[color:var(--surface-1)]/40 py-20 md:py-28">
|
|
<div class="container-page">
|
|
<div class="max-w-2xl mb-12">
|
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Per-key scopes</div>
|
|
<h2 class="text-[28px] md:text-[40px] font-semibold tracking-[-0.025em] leading-[1.06] text-heading">
|
|
Scope down. Stay down.
|
|
</h2>
|
|
<p class="mt-4 text-[15px] text-foreground/70 leading-relaxed">
|
|
A key only does what its scopes allow. A read-scope key cannot create a mailbox. A sequences-scoped key cannot move CRM deals. Cross-scope calls return 403 with scope_required and the exact scope the call needs.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="rounded-[14px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden">
|
|
<div class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,2fr)] px-6 py-3 bg-[color:var(--surface-1)] border-b border-[color:var(--border)] text-[10.5px] uppercase tracking-[0.18em] font-mono text-muted-foreground">
|
|
<div>Scope</div>
|
|
<div>What it grants</div>
|
|
</div>
|
|
{scopes.map((s) => (
|
|
<div class="grid grid-cols-[minmax(0,1.2fr)_minmax(0,2fr)] px-6 py-3.5 border-b border-[color:var(--border)] last:border-b-0 items-baseline">
|
|
<div class="font-mono text-[13px] text-[#0369a1] font-semibold">{s.k}</div>
|
|
<div class="text-[13.5px] text-foreground/75 leading-snug">{s.why}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div class="mt-8 text-[11.5px] font-mono text-muted-foreground">
|
|
Source: <span class="text-foreground/75">internal/api/handler</span> · <span class="text-foreground/75">internal/repository/pg_api_key.go</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ============================================================
|
|
FAQ · animated accordion (same JS as warmup / campaigns)
|
|
============================================================ -->
|
|
<section class="bg-[color:var(--surface-1)]/40 border-t border-[color:var(--border)] py-20 md:py-24">
|
|
<div class="container-page grid lg:grid-cols-[1fr_1.8fr] gap-12 lg:gap-20 items-start">
|
|
<div class="lg:sticky lg:top-24" style="align-self: start;">
|
|
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground font-mono mb-3">Developer FAQ</div>
|
|
<h2 class="text-[28px] md:text-[36px] font-semibold tracking-[-0.025em] leading-[1.08] text-heading">
|
|
Four questions we get most.
|
|
</h2>
|
|
<p class="mt-4 text-[14.5px] text-foreground/70 leading-relaxed">
|
|
Full reference and SDK guides at <a class="text-[#0369a1] hover:text-[#075985] font-medium" href="https://docs.warmbly.com">docs.warmbly.com</a>.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="divide-y divide-[color:var(--border)]" data-faq>
|
|
{faq.map(([q, a]) => (
|
|
<div class="faq-row py-2">
|
|
<button type="button" class="faq-trigger group w-full flex items-start justify-between gap-4 py-3 text-left" aria-expanded="false">
|
|
<span class="text-[15.5px] font-medium text-heading group-hover:text-[#0369a1] transition-colors">{q}</span>
|
|
<span class="faq-icon shrink-0 inline-flex items-center justify-center w-7 h-7 rounded-full bg-white ring-1 ring-[color:var(--border)] text-foreground/60 transition-transform duration-300 ease-out">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
|
|
</span>
|
|
</button>
|
|
<div class="faq-panel grid grid-rows-[0fr] transition-[grid-template-rows] duration-300 ease-out">
|
|
<div class="overflow-hidden">
|
|
<p class="pb-4 pr-12 text-[14px] text-foreground/75 leading-relaxed max-w-2xl">{a}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<script is:inline>
|
|
(function () {
|
|
const faq = document.querySelector('[data-faq]');
|
|
if (!faq) return;
|
|
faq.querySelectorAll('.faq-trigger').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
const row = btn.closest('.faq-row');
|
|
const panel = row.querySelector('.faq-panel');
|
|
const icon = btn.querySelector('.faq-icon');
|
|
const open = btn.getAttribute('aria-expanded') === 'true';
|
|
btn.setAttribute('aria-expanded', String(!open));
|
|
panel.style.gridTemplateRows = open ? '0fr' : '1fr';
|
|
if (icon) icon.style.transform = open ? 'rotate(0deg)' : 'rotate(45deg)';
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
</div>
|
|
</section>
|
|
|
|
<CTA
|
|
title="Wire Warmbly into your stack."
|
|
description="Create a key, paste a webhook URL, ship the integration before lunch."
|
|
primaryLabel="Get an API key"
|
|
primaryHref="https://app.warmbly.com/settings/api-keys"
|
|
secondaryLabel="Read the docs"
|
|
secondaryHref="https://docs.warmbly.com"
|
|
/>
|
|
</Layout>
|
|
</content>
|
|
</invoke> |