API v1Private beta. The API is stable; send limits are low while the shared domain warms up.

Real-time

Webhooks

Signed HTTPS POSTs for new mail, deliveries, bounces and more. Available on every plan.

Create a webhook

const hook = await send0.webhooks.create({
  url: "https://example.com/hooks/send0",
  events: ["message.received"],   // or ["*"] for everything (default)
  inbox_ids: ["ibx_7Hc2…"],       // optional: only these inboxes
});
console.log(hook.secret); // whsec_…  shown once, store it

URLs must be public https://. Private, local and cloud-metadata addresses are refused. An account can have up to 20 webhooks. Managing webhooks needs an admin key that isn't limited to specific inboxes.

Events

TypeWhen
message.receivedA new inbound message was stored
message.sentAn outbound message was handed to our mail servers
message.deliveredThe recipient's server accepted it
message.bouncedIt bounced. Permanent bounces also suppress the address.
message.complainedThe recipient marked it as spam. The address is suppressed.
draft.createdA message is waiting for approval
inbox.suspendedSending was paused for the account, or an inbox was suspended
webhook.testSent by POST /v1/webhooks/{id}/test

The request

POST /hooks/send0
Content-Type: application/json
send0-signature: t=1791190000,v1=5f2c…
send0-event-id: evt_8Qw…
send0-event-type: message.received
send0-delivery-id: dlv_2Lk…
{
  "id": "evt_8Qw…",
  "object": "event",
  "type": "message.received",
  "created_at": "2026-10-06T10:14:03.000Z",
  "inbox_id": "ibx_7Hc2…",
  "data": {
    "id": "msg_9Kd2…",
    "thread_id": "thr_3Lp8…",
    "from": { "name": "Dana", "email": "dana@acme.com" },
    "subject": "Re: PO #4471 delivery date",
    "extracted_text": "Can you confirm Thursday instead?",
    "extracted": { "otp": null, "links": [], "action_link": null },
    "auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass" },
    "safety": { "prompt_injection": "none", "reasons": [] },
    …
  }
}

data is the full message except html. Fetch the message if you need it.

inbox.suspended

data.scope says what stopped. "org": sending is paused for the whole account (receiving still works), either automatically or by our team, and inbox_id is the inbox whose message tipped the rate, or null. "inbox": one inbox was suspended, so it neither sends nor receives (senders get 5.2.1 Mailbox disabled).

{ "type": "inbox.suspended", "inbox_id": null, "data": { "scope": "org", "reason": "complaint rate 0.40% is over 0.3%", "complaint_rate": 0.004, "bounce_rate": 0.01, "sent_30d": 250 } }
{ "type": "inbox.suspended", "inbox_id": "ibx_7Hc2…", "data": { "scope": "inbox", "reason": "Phishing reports", "address": "bot@send0.email" } }

Automatic pauses add complaint_rate, bounce_rate and sent_30d. Write to support@send0.dev to get it lifted.

Verify the signature

Always verify before trusting a webhook. The scheme is the same as Stripe's: v1 is the HMAC-SHA256 of "{t}.{raw body}" with your secret. Requests older than 5 minutes are rejected.

import { verifyWebhook } from "@send0/sdk";

export async function POST(req: Request) {
  const body = await req.text(); // the raw body, before JSON.parse
  if (!(await verifyWebhook(body, req.headers.get("send0-signature"), process.env.SEND0_WEBHOOK_SECRET!))) {
    return new Response("bad signature", { status: 400 });
  }
  const event = JSON.parse(body);
  // … handle event.type
  return new Response("ok");
}

Responding and retries

Return any 2xx within 10 seconds. Anything else (a timeout, a 4xx or 5xx, a redirect) is retried with backoff: 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 8 h, 8 h. That's about 24 hours in total, after which the delivery is marked failed.

Deliveries can arrive more than once or out of order. Use send0-event-id to de-duplicate.

Delivery log, replay and testing

for await (const d of await send0.webhooks.deliveries(hook.id, { status: "failed" })) {
  console.log(d.event_type, d.attempts, d.last_status_code, d.last_error);
  await send0.webhooks.retryDelivery(hook.id, d.id); // replay
}

await send0.webhooks.test(hook.id);         // sends a webhook.test event
await send0.webhooks.rotateSecret(hook.id); // new secret, shown once
await send0.webhooks.update(hook.id, { status: "disabled" });

Every event is stored in the same transaction as the change that caused it, and a background sweep re-queues anything that didn't make it out. An event is never lost between our database and your endpoint.

On this page