Webhooks & Reports
Webhooks
| Method | Path | Description |
|---|---|---|
GET | /webhooks | List your webhooks (signing secrets are never returned again) |
POST | /webhooks | Create one: url, events: [...]. The response includes the signing secret, shown only this once |
PATCH | /webhooks/:id | Change it in place: url, events, active (false pauses it), retryAttempts (1-10), timeoutMs (1000-30000). Only the fields you send change. Its id, signing secret and delivery log stay the same |
DELETE | /webhooks/:id | Delete (its delivery log goes with it) |
POST | /webhooks/:id/test | Send one signed webhook.test event to this webhook and return the real result: { delivered, statusCode, error } |
POST | /webhooks/:id/rotate-secret | New signing secret, shown once. The old one keeps signing too for 24 hours (see below) |
GET | /webhooks/:id/deliveries | Delivery log, newest first. Query: page, limit (max 100), status (PENDING, DELIVERED, FAILED) |
POST | /webhooks/:id/deliveries/:deliveryId/resend | Send a finished delivery again: same body, same event id |
Events
| Event | When | data |
|---|---|---|
email.sent | A campaign email was sent | sendEventId, leadEmail, mailboxEmail |
email.opened | The first open of an email (later opens don’t fire again) | sendEventId, leadEmail, mailboxEmail, campaignId, openedAt |
email.clicked | The first link click in an email | same as opened, plus clickedAt and url |
email.bounced | A send was rejected | sendEventId, leadEmail, bounceType (HARD / SOFT) |
email.replied | A reply was received | replyId, leadId, intentClassification |
email.positive_reply | A reply was classified as interested | replyId, leadId |
lead.unsubscribed | A lead replied asking to be removed. Their sequences stop. | replyId, leadId, leadEmail, source (reply) |
mailbox.disconnected | A mailbox’s login started failing (wrong or revoked password). Fires once per outage. | mailboxId, mailboxEmail, error, disconnectedAt |
mailbox.reconnected | That mailbox logs in again. Fires once. | mailboxId, mailboxEmail, reconnectedAt |
warmup.status_changed | A mailbox’s warmup status changed (started, paused, resumed, moved to the next stage, reached steady state) | mailboxId, mailboxEmail, from, to, reason, changedAt |
placement.completed | An inbox placement test finished (COMPLETED, FAILED or NO_SEED_POOL). Fires once per test. | testId, batchId, mailboxId, mailboxEmail, status, seedCount, inboxCount, spamCount, missingCount, gmailTabs (Gmail seeds: counts per tab, e.g. { "PRIMARY": 11, "PROMOTIONS": 1 }), createdAt, completedAt |
mailbox.bounce_spike | A mailbox’s bounce rate crossed 1.8% (with at least 2 bounces) and it was paused automatically. Fires once per pause. | mailboxId, mailboxEmail, bounceRate, threshold, sentCount, bouncedCount, windowStart, windowEnd, action |
Every request body has the same shape:
{
"id": "evt_7f3a9c1e2b4d6f8a0c1e2b4d",
"event": "email.opened",
"createdAt": "2026-09-26T10:15:00.000Z",
"data": { "sendEventId": "…", "leadEmail": "[email protected]", "mailboxEmail": "[email protected]", "campaignId": "…", "openedAt": "…" }
}Headers
| Header | Value |
|---|---|
X-Sendbox-Event | The event name |
X-Sendbox-Event-Id | The event id, also id in the body. The same on every retry and resend, so use it to ignore duplicates |
X-Sendbox-Timestamp | Unix time (seconds) when this request was sent |
X-Sendbox-Signature | v1=<hex>: HMAC-SHA256 of <timestamp>.<raw body> with your signing secret. During a secret rotation there are two, comma-separated |
Verifying a request
- Read the raw request body (before any JSON parsing).
- Reject the request if
X-Sendbox-Timestampis more than 5 minutes away from your clock. This stops a captured request from being replayed later. - Compute
HMAC-SHA256(secret, timestamp + "." + rawBody)as hex and compare it, in constant time, with eachv1=value inX-Sendbox-Signature. Accept if any matches. - Skip events whose id you have already processed.
Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.SENDBOX_WEBHOOK_SECRET;
app.post("/sendbox-webhook", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Sendbox-Timestamp");
const header = req.get("X-Sendbox-Signature") ?? "";
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400);
const expected = crypto.createHmac("sha256", SECRET).update(`${timestamp}.${req.body}`).digest();
const valid = header.split(",").some((part) => {
const given = Buffer.from(part.trim().replace(/^v1=/, ""), "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
if (!valid) return res.sendStatus(401);
const event = JSON.parse(req.body);
// if (alreadyProcessed(event.id)) return res.sendStatus(200);
console.log(event.event, event.data);
res.sendStatus(200);
});Python (Flask)
import hmac, hashlib, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["SENDBOX_WEBHOOK_SECRET"].encode()
@app.post("/sendbox-webhook")
def sendbox_webhook():
timestamp = request.headers.get("X-Sendbox-Timestamp", "")
header = request.headers.get("X-Sendbox-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
abort(400)
raw = request.get_data()
expected = hmac.new(SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not any(hmac.compare_digest(expected, p.strip().removeprefix("v1=")) for p in header.split(",")):
abort(401)
event = request.get_json()
# skip if event["id"] was already processed
return "", 200Delivery and retries
- Answer with any 2xx within the webhook’s timeout (10 seconds by default). Anything else, a timeout, or a redirect counts as a failure. Redirects are never followed.
- Failed deliveries are retried with exponential backoff: 30 seconds, 1 minute, 2 minutes, 4 minutes and so on, up to the webhook’s attempt limit (3 by default).
- Every delivery is in the log (
GET /webhooks/:id/deliveries, or Settings → API Keys → Deliveries in the dashboard) for 30 days, with its status, attempts and last response. A finished delivery can be resent from there. - Deliveries run separately from sending. A slow or broken endpoint never delays your campaigns.
Rotating the secret
POST /webhooks/:id/rotate-secret returns a new secret. For the next 24 hours every request is signed with both the new and the old secret (two v1= values), so update your endpoint any time in that window without missing events. After 24 hours only the new secret signs.
Why the URL is validated before saving
Sendbox refuses webhook URLs that point at internal/private network addresses or cloud metadata endpoints — this exists to protect your own infrastructure from being used as an unintended target, not to restrict where you can legitimately send events.
Reports
| Method | Path | Description |
|---|---|---|
GET | /reports/campaigns.csv | CSV export of every campaign’s stats, counted as in campaign stats |
GET | /reports/leads.csv | CSV export of up to 50,000 leads. Owner/admin only |
GET | /reports/analytics.csv | CSV export of workspace totals |
GET | /reports/branded | Same numbers as JSON, with white-label branding applied if your plan includes it |