Webhooks
Webhooks push events to your server as they happen: an email was delivered, opened, bounced; a contact was created; mail was received. Jumploon offers two kinds. Most apps want webhook endpoints; the event webhook suits apps that prefer events in batches.
| Webhook endpoints | Event webhook (v3) | |
|---|---|---|
| Endpoints | As many as you like, each with its own events | One per account |
| Delivery | One event per request | Batches of events as a JSON array |
| Signature | webhook-id, webhook-timestamp and webhook-signature headers | X-Mailserver-Timestamp and X-Mailserver-Signature headers |
| Set up | POST /webhooks or Webhooks in the dashboard | PATCH /v3/user/webhooks/event/settings |
Both retry failed deliveries with increasing delays, up to 10 times. A delivery counts as failed unless your server answers with a 2xx status within a few seconds, so respond quickly and do slow work in the background.
Webhook endpoints
Create an endpoint
curl https://api.jumploon.com/webhooks \
-H "Authorization: Bearer $JUMPLOON_API_KEY" -H "Content-Type: application/json" \
-d '{ "endpoint": "https://acme.com/hooks/email", "events": ["email.delivered", "email.bounced", "email.clicked"] }'The response includes the endpoint's signing_secret (it starts with whsec_). Rotate it with POST /webhooks/:id/signing-secret/rotate.
Events
| Event | When |
|---|---|
email.sent | The email was accepted and queued |
email.scheduled | The email was scheduled for later |
email.delivered | The recipient's server accepted it |
email.delivery_delayed | A temporary failure; it will be retried |
email.bounced | Permanently rejected; the address is now suppressed |
email.failed | Couldn't be sent, for example after retries ran out |
email.suppressed | Not sent, because the address is suppressed |
email.opened | The recipient opened it (needs open tracking) |
email.clicked | The recipient clicked a link (needs click tracking) |
email.received | Mail arrived at your inbound host |
contact.created, contact.updated, contact.deleted | Contact changes |
domain.created, domain.updated, domain.deleted | Domain changes |
Delivery events are per recipient. email.complained can be subscribed to, but isn't produced yet, because Jumploon doesn't process mailbox-provider complaint feedback loops yet.
Payload
{
"type": "email.delivered",
"created_at": "2026-10-18T09:41:03.000Z",
"data": {
"email_id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c",
"from": "Acme <hello@acme.com>",
"to": ["ada@example.org"],
"subject": "Your receipt",
"created_at": "2026-10-18T09:41:02.000Z",
"tags": { "type": "receipt" }
}
}Verify the signature
Always verify that a request came from Jumploon before trusting it. Each request carries three headers:
webhook-id: the event's ID, the same on every retry. Use it to ignore duplicates.webhook-timestamp: when it was sent, in Unix seconds.webhook-signature:v1,followed by the base64 HMAC-SHA256 of<id>.<timestamp>.<raw body>, keyed with your signing secret (the part afterwhsec_, base64-decoded).
import crypto from "node:crypto";
export async function POST(req: Request) {
const body = await req.text(); // the raw body, not parsed JSON
const id = req.headers.get("webhook-id")!;
const timestamp = req.headers.get("webhook-timestamp")!;
const key = Buffer.from(process.env.JUMPLOON_WEBHOOK_SECRET!.replace(/^whsec_/, ""), "base64");
const expected = `v1,${crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64")}`;
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;
const signature = req.headers.get("webhook-signature")!;
const valid = signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!fresh || !valid) return new Response(null, { status: 401 });
const event = JSON.parse(body); // event.type, event.data …
return new Response(null, { status: 200 });
}Any library that follows the Standard Webhooks spec can verify these too.
Recent deliveries and their responses are listed at GET /webhooks/:id/events and in the dashboard.
Event webhook (v3)
Enable it
curl -X PATCH https://api.jumploon.com/v3/user/webhooks/event/settings \
-H "Authorization: Bearer $JUMPLOON_API_KEY" -H "Content-Type: application/json" \
-d '{ "enabled": true, "url": "https://acme.com/hooks/events" }'The response includes the signing secret.
Payload
A JSON array of events. Event types: processed, dropped, deferred, delivered, bounce, open, click and unsubscribe. A message's custom_args appear as top-level fields.
[
{
"email": "ada@example.org",
"timestamp": 1792316463,
"event": "delivered",
"sg_event_id": "x9q2m7c4v1b8n3k6",
"sg_message_id": "…",
"category": ["receipt"],
"order_id": "1042"
}
]Events can arrive more than once. Deduplicate on sg_event_id.
Verify the signature
Each batch has X-Mailserver-Timestamp and X-Mailserver-Signature headers. The signature is the hex HMAC-SHA256 of <timestamp>.<raw body>:
import crypto from "node:crypto";
function verify(rawBody: string, timestamp: string, signature: string, secret: string) {
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
return expected.length === signature.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}Reject requests whose timestamp is more than a few minutes old to prevent replays.