Webhooks

Webhooks overview

Receive signed HTTP notifications when emails are delivered, bounce or are opened, and when contacts and domains change.

A webhook is an HTTPS endpoint in your application that Sooner calls when something happens: an email is delivered, a message bounces, a recipient clicks a link, a contact is created, or a domain finishes verifying. Use webhooks instead of polling GET /emails.

Webhooks belong to a project. They only receive events for that project.

Create a webhook

In the dashboard, open Webhooks, enter your endpoint URL, choose events, and click Add endpoint. The signing secret is shown once, right after you create it. Creating webhooks needs the admin role. With the API, use a full_access key:

Shell
curl -X POST https://api.sooner.dev/webhooks \
  -H "Authorization: Bearer $SOONER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "https://yourapp.com/webhooks/sooner",
    "events": ["email.delivered", "email.bounced", "email.complained"]
  }'
JSON
{
  "object": "webhook",
  "id": "jw6k9d3h2s5f8g1j4l7z0x2c",
  "signing_secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
}

Save the signing_secret. You need it to verify signatures.

Endpoint rules

  • The URL must use https.
  • It must not point at a private or local address: localhost, hosts ending in .local or .internal, 127.x, 10.x, 172.16.x to 172.31.x, 192.168.x, 169.254.x, 0.x and [::1]. For local development, expose your machine through a public HTTPS tunnel.
  • events lists the event types to receive. Use "*" to receive every event. Unknown names are ignored, and the request fails with 422 validation_error if none are valid.

Limits

PlanWebhooks per project
Free5
Pro20
Scale50
EnterpriseUnlimited

Over the limit, creating a webhook returns 422 validation_error with the message "Webhook limit reached for your plan."

What Sooner sends

Sooner sends a POST request with a JSON body. Every event has the same envelope:

JSON
{
  "type": "email.delivered",
  "created_at": "2026-11-14T10:31:02.000Z",
  "data": {
    "email_id": "01jcz8w3f2x9k4m7n6p5q2r1s0",
    "from": "Acme <orders@mail.yourcompany.com>",
    "to": ["ada@example.com"],
    "subject": "Your order is confirmed",
    "created_at": "2026-11-14T10:30:58.000Z"
  }
}

type is the event name, created_at is when the event happened, and data depends on the event. See Event types for the fields of each one.

These headers are on every request:

HeaderValue
content-typeapplication/json
user-agentSooner-Webhooks/1.0
svix-idA unique ID for this event, starting with msg_. It is the same on every retry.
svix-timestampUnix time in seconds when this attempt was sent.
svix-signatureThe signature, such as v1,K5oZfzN9....
webhook-id, webhook-timestamp, webhook-signatureThe same three values, for libraries that follow the Standard Webhooks spec.

Respond quickly

Return any 2xx status within 15 seconds to confirm receipt. Anything else counts as a failure and the delivery is retried: a non-2xx status, a timeout, a connection error, or a redirect. Sooner does not follow redirects, so use the final URL. See Retries.

Do the real work after you respond. Acknowledge the request, put the event on a queue or into your database, and process it in the background.

Handle duplicates and ordering

Delivery is at least once. If your endpoint responds slowly or fails, the same event can arrive more than once. Events for one email can also arrive out of order, for example email.delivered before email.sent.

  • Use svix-id as a de-duplication key. Store the IDs you have processed.
  • Use created_at on the event, not the time you received it, to order events.
  • Do not assume a status you derived from a late event is current. Fetch the email with GET /emails/{id} when you need its status.

A minimal receiver

TypeScript
import express from "express";
import { Webhook } from "svix";

const app = express();
const wh = new Webhook(process.env.SOONER_WEBHOOK_SECRET!);

app.post("/webhooks/sooner", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = wh.verify(req.body, {
      "svix-id": req.header("svix-id")!,
      "svix-timestamp": req.header("svix-timestamp")!,
      "svix-signature": req.header("svix-signature")!,
    }) as { type: string; data: Record<string, unknown> };
  } catch {
    return res.status(400).send("invalid signature");
  }

  if (event.type === "email.bounced") {
    console.log("bounced", event.data.email_id);
  }
  res.sendStatus(200);
});

app.listen(3000);

Always verify the signature before you trust a request. See Verify signatures.

Manage webhooks

Pause a webhook with the Enabled checkbox in the dashboard, or by setting its status to disabled with the API. You can also delete it. Events that happen while a webhook is disabled are not sent to it later. In the dashboard, open a webhook to see its recent delivery attempts with their status codes.

Shell
curl -X PATCH https://api.sooner.dev/webhooks/jw6k9d3h2s5f8g1j4l7z0x2c \
  -H "Authorization: Bearer $SOONER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "disabled" }'

See the webhooks API reference for every endpoint.