Skip to content

Webhooks

A webhook sends each event in a universe to your URL as an HTTP POST with a JSON body. Use it to drive another system from what happens in simmail: a reply arrived, a person junked your mail, a link was clicked.

Webhooks need the webhooks feature of your plan and the admin role to change. They are an HTTP notification only; they never carry or send mail.

In the dashboard they are under Settings → Integrations. With the API, PUT /api/webhooks replaces the whole list:

Terminal window
curl -s -X PUT https://c1.simmail.dev/api/webhooks \
-H "Authorization: Bearer smk_your_admin_key" \
-H "Content-Type: application/json" \
-d '[
{
"name": "CI listener",
"url": "https://hooks.yourcompany.test/simmail",
"events": ["persona.sent", "persona.junked", "link.*"],
"secret": "a-long-random-secret"
}
]'
FieldMeaning
urlWhere to POST.
eventsEvent types to send. A name ending in * matches a prefix (persona.*). Leave it out or empty for every event.
secretOptional. Signs each request (below).
nameYour label.

GET /api/webhooks lists them. Secrets come back as ••••; send •••• back unchanged to keep a secret as it is.

POST /simmail HTTP/1.1
Content-Type: application/json
User-Agent: simmail-webhook
X-Simmail-Signature: sha256=5d41402abc4b2a76b9719d911017c592...
{
"type": "persona.sent",
"id": 4182,
"at": "2026-10-10T14:32:05Z",
"source": "simmail",
"data": {
"persona": "Dana Reyes",
"persona_id": "p_3fa2b1c9d0",
"kind": "reply",
"case_id": "deal-1701",
"to": ["app@yourcompany.test"],
"cc": null,
"subject": "Re: Your house on Clark Street",
"message_id": "<9b1f2e3d4c5a6b7c8d9e0f1a@simmail.test>",
"by": "ai",
"links_opened": 0,
"text": "Hi Sam, 165 is a little low…"
},
"persona": {
"id": "p_3fa2b1c9d0",
"name": "Dana Reyes",
"addresses": ["dana@harbor.test"],
"external_ids": { "crm": "c-1042" }
},
"case_id": "deal-1701"
}
FieldMeaning
typeThe event type.
idThe event’s number in the universe’s log.
atWhen it happened, on the universe’s simulated clock.
sourceAlways simmail.
dataThe event’s own fields (below).
personaWhen data has a persona_id: who that is, with your external_ids.
case_idWhen data has one.

With a secret, X-Simmail-Signature is sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with the secret.

import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const want = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return header && crypto.timingSafeEqual(Buffer.from(header), Buffer.from(want));
}
import hashlib, hmac
def verify(raw_body: bytes, header: str, secret: str) -> bool:
want = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(header or "", want)
  • Answer with any 2xx status. Each attempt waits up to 10 seconds.
  • A failed attempt is retried after 3, 9 and 27 seconds: four attempts in all.
  • After that, simmail gives up on that event and records a webhook.failed event with the URL and the error. A webhook with no event filter does not receive webhook.failed itself.
  • Events are sent as they happen, each on its own; their order on arrival is not guaranteed. Use id to put them in order.

These are the events simmail records. The same events feed the Activity view and the event stream.

TypeWhenFields in data
mail.deliveredYour app’s message was accepted.from, to, subject, message_id
persona.receivedA person got your email in their inbox.persona_id, persona, case_id, from, from_name, subject, message_id, text
persona.junkedTheir provider filed it as spam.persona_id, persona, subject, message_id, score, threshold, why
persona.ignoredThey chose not to answer, or their AI is off.persona_id, persona, case_id, subject, why
persona.batchedThey will answer it together with a reply already planned.persona_id, persona, subject, why
persona.sentThey sent an email. kind is reply, followup, correction, ooo, initiate, deliver or nudge.persona_id, persona, kind, case_id, to, cc, subject, message_id, by, links_opened, text
persona.bouncedA dead, full or blocking address bounced your mail.persona_id, address, kind, to, subject
persona.promisedA person promised to do something.persona_id, persona, to, what, due, will_keep, job_id
persona.waitingA person is waiting on something you promised.persona_id, persona, from, what, due, job_id
persona.createdA person was created by simmail (a stranger, the World, or learned from mail).persona_id, persona, address, temperament, mode, why
persona.updatedA person was created or changed through the API, or the AI filled in a stranger.id, name
persona.memorySomething was added to a person’s memory through the API.persona_id, case_id, source
case.updatedA case was pushed.id, title, new, changed
TypeFields in data
link.openedpersona_id, persona, url, text, message_id, why, status, final_url, title, content_type, ms
link.failedpersona_id, persona, url, text, message_id, why, error, ms
link.skippedpersona_id, persona, url, message_id, why (and domain for a skipped sender look-up)
link.senderpersona_id, persona, domain, message_id, url, reason; then status, final_url, title, ms for a site that loaded, or why, quality, error when there was none
TypeWhenFields in data
inspect.scoredYour message got its spam score.message_id, subject, score, engine, action
inspect.linksIts links were checked.message_id, subject, links, broken
world.mailThe World sent the company an email.stream, kind, to, and persona, persona_id or from, subject, message_id
world.updatedThe World settings changed.streams, enabled
job.scheduledSomething was planned for later.id, kind, persona_id, due, plan
job.done, job.failedIt ran.id, kind, persona_id, note
job.updatedIt was skipped or rescheduled.id, state, due, kind
TypeWhen
auth.ok, auth.failedAn SMTP, IMAP or dashboard login (proto, username, address, remote, why).
clockThe clock changed (now, speed, frozen, real).
paceThe pace changed (factor, ignore_hours).
faults.updatedFault rules changed (rules, scenario).
retention.prunedOld mail or events were removed (messages, events, jobs).
writer.fallbackThe AI failed and templates stood in (what, error).
webhook.failedA webhook gave up (url, error).