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.
Setting them up
Section titled “Setting them up”In the dashboard they are under Settings → Integrations. With the API, PUT /api/webhooks replaces the whole list:
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" } ]'| Field | Meaning |
|---|---|
url | Where to POST. |
events | Event types to send. A name ending in * matches a prefix (persona.*). Leave it out or empty for every event. |
secret | Optional. Signs each request (below). |
name | Your label. |
GET /api/webhooks lists them. Secrets come back as ••••; send •••• back unchanged to keep a secret as it is.
The request
Section titled “The request”POST /simmail HTTP/1.1Content-Type: application/jsonUser-Agent: simmail-webhookX-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", "cc": null, "subject": "Re: Your house on Clark Street", "by": "ai", "links_opened": 0, "text": "Hi Sam, 165 is a little low…" }, "persona": { "id": "p_3fa2b1c9d0", "name": "Dana Reyes", "external_ids": { "crm": "c-1042" } }, "case_id": "deal-1701"}| Field | Meaning |
|---|---|
type | The event type. |
id | The event’s number in the universe’s log. |
at | When it happened, on the universe’s simulated clock. |
source | Always simmail. |
data | The event’s own fields (below). |
persona | When data has a persona_id: who that is, with your external_ids. |
case_id | When data has one. |
Verifying the signature
Section titled “Verifying the signature”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)Delivery and retries
Section titled “Delivery and retries”- 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.failedevent with the URL and the error. A webhook with no event filter does not receivewebhook.faileditself. - Events are sent as they happen, each on its own; their order on arrival is not guaranteed. Use
idto put them in order.
Event types
Section titled “Event types”These are the events simmail records. The same events feed the Activity view and the event stream.
Mail and people
Section titled “Mail and people”| Type | When | Fields in data |
|---|---|---|
mail.delivered | Your app’s message was accepted. | from, to, subject, message_id |
persona.received | A person got your email in their inbox. | persona_id, persona, case_id, from, from_name, subject, message_id, text |
persona.junked | Their provider filed it as spam. | persona_id, persona, subject, message_id, score, threshold, why |
persona.ignored | They chose not to answer, or their AI is off. | persona_id, persona, case_id, subject, why |
persona.batched | They will answer it together with a reply already planned. | persona_id, persona, subject, why |
persona.sent | They 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.bounced | A dead, full or blocking address bounced your mail. | persona_id, address, kind, to, subject |
persona.promised | A person promised to do something. | persona_id, persona, to, what, due, will_keep, job_id |
persona.waiting | A person is waiting on something you promised. | persona_id, persona, from, what, due, job_id |
persona.created | A person was created by simmail (a stranger, the World, or learned from mail). | persona_id, persona, address, temperament, mode, why |
persona.updated | A person was created or changed through the API, or the AI filled in a stranger. | id, name |
persona.memory | Something was added to a person’s memory through the API. | persona_id, case_id, source |
case.updated | A case was pushed. | id, title, new, changed |
| Type | Fields in data |
|---|---|
link.opened | persona_id, persona, url, text, message_id, why, status, final_url, title, content_type, ms |
link.failed | persona_id, persona, url, text, message_id, why, error, ms |
link.skipped | persona_id, persona, url, message_id, why (and domain for a skipped sender look-up) |
link.sender | persona_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 |
Health, the World and the queue
Section titled “Health, the World and the queue”| Type | When | Fields in data |
|---|---|---|
inspect.scored | Your message got its spam score. | message_id, subject, score, engine, action |
inspect.links | Its links were checked. | message_id, subject, links, broken |
world.mail | The World sent the company an email. | stream, kind, to, and persona, persona_id or from, subject, message_id |
world.updated | The World settings changed. | streams, enabled |
job.scheduled | Something was planned for later. | id, kind, persona_id, due, plan |
job.done, job.failed | It ran. | id, kind, persona_id, note |
job.updated | It was skipped or rescheduled. | id, state, due, kind |
The universe
Section titled “The universe”| Type | When |
|---|---|
auth.ok, auth.failed | An SMTP, IMAP or dashboard login (proto, username, address, remote, why). |
clock | The clock changed (now, speed, frozen, real). |
pace | The pace changed (factor, ignore_hours). |
faults.updated | Fault rules changed (rules, scenario). |
retention.pruned | Old mail or events were removed (messages, events, jobs). |
writer.fallback | The AI failed and templates stood in (what, error). |
webhook.failed | A webhook gave up (url, error). |