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:
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"]
}'{
"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.localor.internal,127.x,10.x,172.16.xto172.31.x,192.168.x,169.254.x,0.xand[::1]. For local development, expose your machine through a public HTTPS tunnel. eventslists the event types to receive. Use"*"to receive every event. Unknown names are ignored, and the request fails with422 validation_errorif none are valid.
Limits
| Plan | Webhooks per project |
|---|---|
| Free | 5 |
| Pro | 20 |
| Scale | 50 |
| Enterprise | Unlimited |
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:
{
"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:
| Header | Value |
|---|---|
content-type | application/json |
user-agent | Sooner-Webhooks/1.0 |
svix-id | A unique ID for this event, starting with msg_. It is the same on every retry. |
svix-timestamp | Unix time in seconds when this attempt was sent. |
svix-signature | The signature, such as v1,K5oZfzN9.... |
webhook-id, webhook-timestamp, webhook-signature | The 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-idas a de-duplication key. Store the IDs you have processed. - Use
created_aton 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
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.
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.