Skip to Content
🚀 Sendbox is live — this documentation is a work in progress.
API ReferenceWebhooks & Reports

Webhooks & Reports

Webhooks

MethodPathDescription
GET/webhooksList your webhooks (signing secrets are never returned again)
POST/webhooksCreate one: url, events: [...]. The response includes the signing secret, shown only this once
PATCH/webhooks/:idChange 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/:idDelete (its delivery log goes with it)
POST/webhooks/:id/testSend one signed webhook.test event to this webhook and return the real result: { delivered, statusCode, error }
POST/webhooks/:id/rotate-secretNew signing secret, shown once. The old one keeps signing too for 24 hours (see below)
GET/webhooks/:id/deliveriesDelivery log, newest first. Query: page, limit (max 100), status (PENDING, DELIVERED, FAILED)
POST/webhooks/:id/deliveries/:deliveryId/resendSend a finished delivery again: same body, same event id

Events

EventWhendata
email.sentA campaign email was sentsendEventId, leadEmail, mailboxEmail
email.openedThe first open of an email (later opens don’t fire again)sendEventId, leadEmail, mailboxEmail, campaignId, openedAt
email.clickedThe first link click in an emailsame as opened, plus clickedAt and url
email.bouncedA send was rejectedsendEventId, leadEmail, bounceType (HARD / SOFT)
email.repliedA reply was receivedreplyId, leadId, intentClassification
email.positive_replyA reply was classified as interestedreplyId, leadId
lead.unsubscribedA lead replied asking to be removed. Their sequences stop.replyId, leadId, leadEmail, source (reply)
mailbox.disconnectedA mailbox’s login started failing (wrong or revoked password). Fires once per outage.mailboxId, mailboxEmail, error, disconnectedAt
mailbox.reconnectedThat mailbox logs in again. Fires once.mailboxId, mailboxEmail, reconnectedAt
warmup.status_changedA mailbox’s warmup status changed (started, paused, resumed, moved to the next stage, reached steady state)mailboxId, mailboxEmail, from, to, reason, changedAt
placement.completedAn 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_spikeA 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

HeaderValue
X-Sendbox-EventThe event name
X-Sendbox-Event-IdThe event id, also id in the body. The same on every retry and resend, so use it to ignore duplicates
X-Sendbox-TimestampUnix time (seconds) when this request was sent
X-Sendbox-Signaturev1=<hex>: HMAC-SHA256 of <timestamp>.<raw body> with your signing secret. During a secret rotation there are two, comma-separated

Verifying a request

  1. Read the raw request body (before any JSON parsing).
  2. Reject the request if X-Sendbox-Timestamp is more than 5 minutes away from your clock. This stops a captured request from being replayed later.
  3. Compute HMAC-SHA256(secret, timestamp + "." + rawBody) as hex and compare it, in constant time, with each v1= value in X-Sendbox-Signature. Accept if any matches.
  4. 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 "", 200

Delivery 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

MethodPathDescription
GET/reports/campaigns.csvCSV export of every campaign’s stats, counted as in campaign stats
GET/reports/leads.csvCSV export of up to 50,000 leads. Owner/admin only
GET/reports/analytics.csvCSV export of workspace totals
GET/reports/brandedSame numbers as JSON, with white-label branding applied if your plan includes it